This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit bd62c1e3a8da33afee409a241a8496bb83f1881f
Author: James Bognar <[email protected]>
AuthorDate: Tue Aug 18 13:37:55 2026 -0400

    FINISHED-411/412/413: Resolve ConsoleChromeMixin's chrome.css/logo/page-bg 
endpoints under both mount styles
    
    ConsoleChromeMixin's @RestGet paths are absolute-looking literals baked into
    the jar. Juneau resolves an operation's path against getContextPath() +
    getServletPath(), so the same mixin resolves differently depending on 
whether
    the host resource is mounted "composed" (hanging off an app's existing 
/rest/*
    mount) or "standalone" (its own /juneau-console/* servlet mapping) -- one of
    the two mount styles was always one path segment short.
    
    Fixed by declaring both a PREFIXED and UNPREFIXED @RestGet path per endpoint
    and picking the live one from servletPath at request time; the new 
assetUrl()
    resolver makes the CSS's own emitted url(...) references resolve the same 
way.
    cachedBody becomes cachedBodies, keyed by mount, since the two mount styles 
now
    serve genuinely different bytes from one mixin instance.
    
    Also: RestOpContext.matchPattern changes from "keep looping after the first
    match" to an early return on the same dispatch path this fix touches.
    Behaviorally a no-op today (it never overwrote the first match) -- included
    here as a small cleanup on the code this commit is already re-reading, not 
as a
    claimed behavior fix.
    
    Partial-file note: the post-backup addition to ConsoleChromeMixin_Test is 
one
    contiguous block that interleaves this fix's tests with an unrelated theme
    fix's tests. This commit contains only test methods l01-l06 and m01-m12 from
    that block (18 tests: standalone-vs-composed mount resolution, URL-prefix
    correctness, cache-buster/cached-body mount independence), plus the four
    private helpers they need. A sibling commit adds n01-o02 to the same file 
for a
    red-tag/token-order fix that happens to append to the same block. Reading
    either commit in isolation, the file will already contain test methods the
    diff does not touch -- that is expected, not a sign the split is wrong.
---
 .../rest/server/console/ConsoleChromeMixin.java    | 103 ++++++++--
 .../server/console/ConsoleChromeMixin_Test.java    | 208 +++++++++++++++++++++
 .../apache/juneau/rest/server/RestOpContext.java   |  11 +-
 3 files changed, 300 insertions(+), 22 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
 
b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
index 423efb4a25..70ac79517c 100644
--- 
a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
@@ -45,6 +45,30 @@ import org.apache.juneau.rest.server.*;
  * structural {@code chrome.css} (shipped in this module's classpath) with the 
active theme's tokens appended as a
  * {@code :root{}} block.
  *
+ * <h5 class='section'>Mount styles:</h5>
+ * <p>
+ * Both of the following arrangements serve the assets at the {@link 
#CHROME_CSS_PATH} /
+ * {@link #LOGO_ASSET_PATH} / {@link #PAGE_BG_ASSET_PATH} URLs with no path 
juggling by the host resource:
+ * <ul>
+ *     <li><b>Composed</b> &mdash; the host resource is mounted wherever the 
application already mounts it (e.g.
+ *             {@code /rest/*}) and the assets hang off that mount, at
+ *             <code>&lt;host-mount&gt;/juneau-console/chrome.css</code>.
+ *     <li><b>Standalone</b> &mdash; the host resource is registered with the 
servlet container at url-pattern
+ *             {@code /juneau-console/*} so the assets sit at a fixed 
site-root URL, independent of which page or tab
+ *             rendered the referencing {@code <link>}.
+ * </ul>
+ * <p>
+ * The two arrangements need different operation paths, because a container 
mount at {@code /juneau-console/*}
+ * reports {@code servletPath="/juneau-console"} and Juneau resolves an 
operation's path against the request URI
+ * with {@code contextPath + servletPath} already removed &mdash; leaving only 
{@code /chrome.css} to match.
+ * Each operation below therefore declares <i>both</i> its prefixed path and 
the same path minus the
+ * {@code /juneau-console} prefix, so whichever one the arrangement leaves to 
be matched resolves.
+ * <p>
+ * The same duality applies to the logo/page-background {@code url()}s written 
into the served stylesheet: they are
+ * resolved per-request against the container's context path and the host's 
mount, picking the path form the active
+ * arrangement leaves unconsumed, so the browser fetches them from the mount 
that served the stylesheet rather than
+ * from the site root.
+ *
  * <h5 class='section'>Theme precedence:</h5>
  * <p>
  * <code>{@link Builder#theme(Theme) mixin.theme(...)}</code> wins over a 
{@link ThemeSettings} {@code BeanStore}
@@ -73,6 +97,18 @@ public class ConsoleChromeMixin {
        /** The URL path at which the configured page-background asset is 
served (relative to the host mount). */
        public static final String PAGE_BG_ASSET_PATH = 
"/juneau-console/assets/page-bg";
 
+       /** {@link #CHROME_CSS_PATH} minus the {@code /juneau-console} prefix - 
see the class javadoc's mount-styles section. */
+       static final String CHROME_CSS_PATH_UNPREFIXED = "/chrome.css";
+
+       /** {@link #LOGO_ASSET_PATH} minus the {@code /juneau-console} prefix - 
see the class javadoc's mount-styles section. */
+       static final String LOGO_ASSET_PATH_UNPREFIXED = "/assets/logo";
+
+       /** {@link #PAGE_BG_ASSET_PATH} minus the {@code /juneau-console} 
prefix - see the class javadoc's mount-styles section. */
+       static final String PAGE_BG_ASSET_PATH_UNPREFIXED = "/assets/page-bg";
+
+       /** The prefix the {@code *_UNPREFIXED} constants drop - see the class 
javadoc's mount-styles section. */
+       static final String MOUNT_PREFIX = "/juneau-console";
+
        /** Classpath location of the shipped structural stylesheet. */
        static final String CHROME_CSS_RESOURCE = 
"/org/apache/juneau/console/chrome.css";
 
@@ -96,8 +132,8 @@ public class ConsoleChromeMixin {
        private final String logoResource;
        private final String pageBackgroundResource;
 
-       /** Per-mixin-instance cache of the fully-assembled (static + theme 
blocks) response body. Never shared across mounts. */
-       private volatile byte[] cachedBody;
+       /** Per-mixin-instance cache of the fully-assembled (static + theme 
blocks) response body, keyed by mount (see {@link #mountKey}). */
+       private final Map<String,byte[]> cachedBodies = new 
ConcurrentHashMap<>();
 
        /** Test-only diagnostic: counts every call to {@link 
#buildBody(RestRequest)} (i.e. every cache miss / every call when caching is 
disabled). */
        private final AtomicInteger buildCount = new AtomicInteger();
@@ -140,7 +176,7 @@ public class ConsoleChromeMixin {
         *      &mdash; the resource is shipped in the same jar as this class).
         */
        @RestGet(
-               path=CHROME_CSS_PATH,
+               path={CHROME_CSS_PATH, CHROME_CSS_PATH_UNPREFIXED},
                summary="Admin-console chrome stylesheet",
                description="Structural CSS for the admin-console chrome, with 
the active theme's tokens appended.",
                swagger=@OpSwagger(ignore=true)
@@ -157,7 +193,7 @@ public class ConsoleChromeMixin {
         * @throws IOException If the configured resource could not be read.
         */
        @RestGet(
-               path=LOGO_ASSET_PATH,
+               path={LOGO_ASSET_PATH, LOGO_ASSET_PATH_UNPREFIXED},
                summary="Configured logo image asset",
                swagger=@OpSwagger(ignore=true)
        )
@@ -174,7 +210,7 @@ public class ConsoleChromeMixin {
         * @throws IOException If the configured resource could not be read.
         */
        @RestGet(
-               path=PAGE_BG_ASSET_PATH,
+               path={PAGE_BG_ASSET_PATH, PAGE_BG_ASSET_PATH_UNPREFIXED},
                summary="Configured page-background image asset",
                swagger=@OpSwagger(ignore=true)
        )
@@ -201,19 +237,31 @@ public class ConsoleChromeMixin {
                );
        }
 
-       /** Returns the fully-assembled response body, computing (and 
instance-caching) it on first call. */
+       /**
+        * Returns the fully-assembled response body for the mount the request 
arrived under, computing (and caching) it
+        * on first call for that mount.
+        *
+        * <p>
+        * The cache is keyed by mount rather than held in a single field 
because the emitted asset URLs are mount-derived
+        * (see {@link #assetUrl}), and one mixin instance can be reached under 
more than one container mapping.
+        */
        private byte[] cachedBody(RestRequest req) throws IOException {
-               var b = cachedBody;
-               if (b == null) {
-                       synchronized (this) {
-                               b = cachedBody;
-                               if (b == null) {  // HTT: the "already set" 
branch is only reachable under a lock-acquisition race - unhittable 
single-threaded.
-                                       b = buildBody(req);
-                                       cachedBody = b;
+               try {
+                       return cachedBodies.computeIfAbsent(mountKey(req), k -> 
{
+                               try {
+                                       return buildBody(req);
+                               } catch (IOException e) {  // HTT: staticCss() 
is the only throwing call and reads a resource shipped in this jar.
+                                       throw new UncheckedIOException(e);
                                }
-                       }
+                       });
+               } catch (UncheckedIOException e) {  // HTT: see above - the 
wrapped read cannot fail in a well-formed jar.
+                       throw e.getCause();
                }
-               return b;
+       }
+
+       /** The request's mount identity: the two request properties every 
emitted asset URL is derived from. */
+       private static String mountKey(RestRequest req) {
+               return req.getContextPath() + '\n' + req.getServletPath();
        }
 
        /**
@@ -231,16 +279,37 @@ public class ConsoleChromeMixin {
                if (! active.getName().equals(Theme.OPEN.getName()))
                        sb.append('\n').append(rootBlock(active));
                if (pageBackgroundResource != null)
-                       sb.append('\n').append("html, 
body{background-image:url(\"").append(PAGE_BG_ASSET_PATH)
+                       sb.append('\n').append("html, 
body{background-image:url(\"").append(assetUrl(req, PAGE_BG_ASSET_PATH, 
PAGE_BG_ASSET_PATH_UNPREFIXED))
                                
.append("?v=").append(buildVersion()).append('-').append(assetContentHash(pageBackgroundResource))
                                .append("\"), var(--jc-page-bg);}");
                if (logoResource != null)
-                       
sb.append('\n').append(".jc-logo{background-image:url(\"").append(LOGO_ASSET_PATH)
+                       
sb.append('\n').append(".jc-logo{background-image:url(\"").append(assetUrl(req, 
LOGO_ASSET_PATH, LOGO_ASSET_PATH_UNPREFIXED))
                                
.append("?v=").append(buildVersion()).append('-').append(assetContentHash(logoResource))
                                .append("\");}");
                return sb.toString().getBytes(StandardCharsets.UTF_8);
        }
 
+       /**
+        * Resolves one of the mixin's asset endpoints to a URL a browser can 
fetch from wherever the referencing
+        * {@code chrome.css} was served: the container's context path and the 
host's mount, plus whichever of the
+        * endpoint's two declared paths the active mount leaves to be matched.
+        *
+        * <p>
+        * Under the <b>standalone</b> mount style the container has already 
consumed the {@code /juneau-console} segment
+        * into {@code servletPath}, so resolving the prefixed path against it 
would emit that segment twice; under the
+        * <b>composed</b> style {@code servletPath} is the host's own mount 
and the prefixed segment is exactly what is
+        * missing. {@code servletPath} ending in {@code /juneau-console} 
therefore identifies the standalone case.
+        *
+        * <p>
+        * A composing host whose own mount happens to end in {@code 
/juneau-console} reads as standalone here, which
+        * emits its unprefixed URL rather than its prefixed one - still a live 
URL, since every mount serves both forms
+        * (see the class javadoc's mount-styles section).
+        */
+       private static String assetUrl(RestRequest req, String prefixedPath, 
String unprefixedPath) {
+               var standalone = req.getServletPath().endsWith(MOUNT_PREFIX);
+               return req.getUriResolver().resolve("servlet:" + (standalone ? 
unprefixedPath : prefixedPath));
+       }
+
        /**
         * Resolves the framework build version for asset cache-busting, 
falling back to {@code "dev"} when unset
         * (e.g. running from IDE/test classpath rather than a packaged jar).
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
index d1610f82f6..c91e5e0aca 100644
--- 
a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
@@ -22,6 +22,7 @@ import java.io.*;
 import java.nio.charset.*;
 import java.util.*;
 import java.util.regex.*;
+import java.util.zip.*;
 
 import org.apache.juneau.*;
 import org.apache.juneau.commons.inject.*;
@@ -543,10 +544,217 @@ class ConsoleChromeMixin_Test extends TestBase {
                }
        }
 
+       
//-----------------------------------------------------------------------------------------------------------------
+       // l) Mount-style independence: standalone container mount at 
/juneau-console/* vs. composed onto a host
+       //    mounted elsewhere.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       /**
+        * A container mount at url-pattern {@code /juneau-console/*} makes the 
container report
+        * {@code servletPath="/juneau-console"}, so the path Juneau matches 
against is only the remainder
+        * ({@code /chrome.css}). The mixin's endpoints must resolve at the 
stable
+        * {@code /juneau-console/chrome.css} URL in that arrangement without 
the host having to rewrite
+        * {@code getServletPath()}.
+        */
+       private static MockRestClient standaloneMounted(Class<?> host) {
+               return 
MockRestClient.createLax(host).servletPath("/juneau-console").build();
+       }
+
+       @Test void l01_standaloneMount_chromeCssResolvesAtStableUrl() throws 
Exception {
+               standaloneMounted(AssetsHost.class).get("/chrome.css").run()
+                       .assertStatus(200)
+                       .assertHeader("Content-Type").isContains("text/css");
+       }
+
+       @Test void 
l02_standaloneMount_logoAssetResolvesAtUrlEmittedInChromeCss() throws Exception 
{
+               standaloneMounted(AssetsHost.class).get("/assets/logo").run()
+                       .assertStatus(200)
+                       
.assertHeader("Content-Type").isContains("image/svg+xml");
+       }
+
+       @Test void 
l03_standaloneMount_pageBgAssetResolvesAtUrlEmittedInChromeCss() throws 
Exception {
+               standaloneMounted(AssetsHost.class).get("/assets/page-bg").run()
+                       .assertStatus(200)
+                       .assertHeader("Content-Type").isContains("image/png");
+       }
+
+       @Test void 
l04_standaloneMount_emittedAssetUrlsAreServableUnderThatMount() throws 
Exception {
+               // The chrome.css body references the assets by their absolute 
/juneau-console/... URL.  Under a
+               // /juneau-console/* container mount at the site root those 
URLs must hit the very endpoints above.
+               var body = 
standaloneMounted(AssetsHost.class).get("/chrome.css").run().assertStatus(200).getContent().asString();
+               assertTrue(body.contains("url(\"" + 
ConsoleChromeMixin.LOGO_ASSET_PATH + "?v="), () -> "missing logo url(), 
body:\n" + body);
+               assertTrue(body.contains("url(\"" + 
ConsoleChromeMixin.PAGE_BG_ASSET_PATH + "?v="), () -> "missing page-bg url(), 
body:\n" + body);
+       }
+
+       @Test void 
l05_composedMount_prefixedPathsStillResolveUnderANonRootHostMount() throws 
Exception {
+               // Back-compat guard for the documented composition style: a 
host mounted at /rest/* keeps serving the
+               // mixin's endpoints at <host-mount>/juneau-console/...
+               var c = 
MockRestClient.createLax(AssetsHost.class).servletPath("/rest").build();
+               
c.get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200);
+               
c.get(ConsoleChromeMixin.LOGO_ASSET_PATH).run().assertStatus(200);
+               
c.get(ConsoleChromeMixin.PAGE_BG_ASSET_PATH).run().assertStatus(200);
+       }
+
+       @Test void l06_publicAssetPathConstants_arePinned() {
+               // These constants are the URLs consumers build <link>/<img> 
references from - changing a value is a
+               // silent break for every deployed consumer, so pin them.
+               assertEquals("/juneau-console/chrome.css", 
ConsoleChromeMixin.CHROME_CSS_PATH);
+               assertEquals("/juneau-console/assets/logo", 
ConsoleChromeMixin.LOGO_ASSET_PATH);
+               assertEquals("/juneau-console/assets/page-bg", 
ConsoleChromeMixin.PAGE_BG_ASSET_PATH);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // m) Mount-style-aware asset URL generation: the logo/page-bg url()s 
written into the served chrome.css must be
+       //    fetchable by the browser under both mount styles and at any 
container context path.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void 
m01_standaloneMount_emittedLogoUrl_isByteIdenticalToThePreFixLiteral() throws 
Exception {
+               var body = 
standaloneMounted(AssetsHost.class).get("/chrome.css").run().assertStatus(200).getContent().asString();
+               assertEquals("/juneau-console/assets/logo" + 
expectedCacheBuster(VALID_LOGO), emittedUrl(body, "/assets/logo"));
+       }
+
+       @Test void 
m02_standaloneMount_emittedPageBgUrl_isByteIdenticalToThePreFixLiteral() throws 
Exception {
+               var body = 
standaloneMounted(AssetsHost.class).get("/chrome.css").run().assertStatus(200).getContent().asString();
+               assertEquals("/juneau-console/assets/page-bg" + 
expectedCacheBuster(VALID_PAGE_BG), emittedUrl(body, "/assets/page-bg"));
+       }
+
+       /**
+        * The regression this guards is mount-style detection inverted in the 
standalone direction: resolving the
+        * <i>prefixed</i> constant against a standalone mount's {@code 
servletPath} (which already ends in
+        * {@code /juneau-console}) doubles the segment up to {@code 
/juneau-console/juneau-console/assets/logo}. A
+        * happy-path "the url contains /assets/logo" assertion passes right 
through that, and so does an end-to-end
+        * fetch, because the doubled URL is a live alias under a standalone 
mount.
+        */
+       @Test void m03_standaloneMount_emittedUrlsAreNotDoublePrefixed() throws 
Exception {
+               var body = 
standaloneMounted(AssetsHost.class).get("/chrome.css").run().assertStatus(200).getContent().asString();
+               assertFalse(body.contains("/juneau-console/juneau-console"), () 
-> "asset url double-prefixed, body:\n" + body);
+       }
+
+       @Test void 
m04_composedMount_emittedLogoUrl_carriesTheHostMountSegment() throws Exception {
+               var body = 
composedMounted(AssetsHost.class).get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
+               assertEquals("/rest/juneau-console/assets/logo" + 
expectedCacheBuster(VALID_LOGO), emittedUrl(body, "/assets/logo"));
+       }
+
+       @Test void 
m05_composedMount_emittedPageBgUrl_carriesTheHostMountSegment() throws 
Exception {
+               var body = 
composedMounted(AssetsHost.class).get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
+               assertEquals("/rest/juneau-console/assets/page-bg" + 
expectedCacheBuster(VALID_PAGE_BG), emittedUrl(body, "/assets/page-bg"));
+       }
+
+       /**
+        * The mirror-image regression of (m03): detection inverted in the 
composed direction, resolving the
+        * <i>unprefixed</i> constant under a composed mount, yields {@code 
/rest/assets/logo}. That is not caught by an
+        * end-to-end fetch either &mdash; {@code TODO-411}'s dual path 
registration makes {@code /rest/assets/logo} a
+        * live alias too &mdash; nor by the pre-fix root-absolute literal 
{@code /juneau-console/assets/logo}, which
+        * still <i>looks</i> like a plausible logo URL. Both wrong answers are 
pinned out explicitly here.
+        */
+       @Test void 
m06_composedMount_emittedUrlsAreNeitherUnprefixedNorSiteRootAbsolute() throws 
Exception {
+               var body = 
composedMounted(AssetsHost.class).get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
+               var logoUrl = emittedUrl(body, "/assets/logo");
+               assertFalse(logoUrl.startsWith("/rest/assets/"), () -> 
"unprefixed constant resolved under a composed mount: " + logoUrl);
+               assertFalse(logoUrl.startsWith("/juneau-console/"), () -> 
"site-root-absolute literal emitted under a composed mount: " + logoUrl);
+               
assertFalse(body.contains("/rest/juneau-console/juneau-console"), () -> "asset 
url double-prefixed, body:\n" + body);
+       }
+
+       @Test void 
m07_composedMountUnderANonEmptyContextPath_emittedUrlsCarryTheContextPath() 
throws Exception {
+               var c = 
MockRestClient.createLax(AssetsHost.class).contextPath("/app").servletPath("/rest").build();
+               var body = 
c.get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
+               assertEquals("/app/rest/juneau-console/assets/logo" + 
expectedCacheBuster(VALID_LOGO), emittedUrl(body, "/assets/logo"));
+               assertEquals("/app/rest/juneau-console/assets/page-bg" + 
expectedCacheBuster(VALID_PAGE_BG), emittedUrl(body, "/assets/page-bg"));
+       }
+
+       @Test void 
m08_standaloneMountUnderANonEmptyContextPath_emittedUrlsCarryTheContextPath() 
throws Exception {
+               var c = 
MockRestClient.createLax(AssetsHost.class).contextPath("/app").servletPath("/juneau-console").build();
+               var body = 
c.get("/chrome.css").run().assertStatus(200).getContent().asString();
+               assertEquals("/app/juneau-console/assets/logo" + 
expectedCacheBuster(VALID_LOGO), emittedUrl(body, "/assets/logo"));
+               assertEquals("/app/juneau-console/assets/page-bg" + 
expectedCacheBuster(VALID_PAGE_BG), emittedUrl(body, "/assets/page-bg"));
+       }
+
+       @Test void 
m09_composedMount_emittedUrlsActuallyResolveAgainstThatMount() throws Exception 
{
+               var c = composedMounted(AssetsHost.class);
+               var body = 
c.get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
+               c.get(belowMount(emittedUrl(body, "/assets/logo"), 
"/rest")).run().assertStatus(200).assertHeader("Content-Type").isContains("image/svg+xml");
+               c.get(belowMount(emittedUrl(body, "/assets/page-bg"), 
"/rest")).run().assertStatus(200).assertHeader("Content-Type").isContains("image/png");
+       }
+
+       @Test void 
m10_standaloneMount_emittedUrlsActuallyResolveAgainstThatMount() throws 
Exception {
+               var c = standaloneMounted(AssetsHost.class);
+               var body = 
c.get("/chrome.css").run().assertStatus(200).getContent().asString();
+               c.get(belowMount(emittedUrl(body, "/assets/logo"), 
"/juneau-console")).run().assertStatus(200).assertHeader("Content-Type").isContains("image/svg+xml");
+               c.get(belowMount(emittedUrl(body, "/assets/page-bg"), 
"/juneau-console")).run().assertStatus(200).assertHeader("Content-Type").isContains("image/png");
+       }
+
+       @Test void m11_cacheBuster_isMountIndependent() throws Exception {
+               // The ?v= suffix hashes the configured asset's own bytes and 
reads the package version - neither has
+               // anything to do with the mount, so only the URL prefix may 
differ between the two mount styles.
+               var standalone = 
emittedUrl(standaloneMounted(AssetsHost.class).get("/chrome.css").run().getContent().asString(),
 "/assets/logo");
+               var composed = 
emittedUrl(composedMounted(AssetsHost.class).get(ConsoleChromeMixin.CHROME_CSS_PATH).run().getContent().asString(),
 "/assets/logo");
+               var buster = expectedCacheBuster(VALID_LOGO);
+               assertTrue(standalone.endsWith(buster), () -> "standalone url 
lost its cache-buster: " + standalone);
+               assertTrue(composed.endsWith(buster), () -> "composed url lost 
its cache-buster: " + composed);
+       }
+
+       static final ConsoleChromeMixin MOUNT_CACHE_MIXIN = 
ConsoleChromeMixin.create().logo(VALID_LOGO).build();
+
+       @Rest(mixins=ConsoleChromeMixin.class)
+       public static class MountCacheHost extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public ConsoleChromeMixin console() { return 
MOUNT_CACHE_MIXIN; }
+       }
+
+       /**
+        * {@code cacheAssets(true)} caches the assembled body, and the body 
now varies by mount - so the cache has to
+        * key on the mount. A body cache that ignores the mount serves 
whichever mount style happened to warm it first
+        * to the other one, which no single-mount test can see.
+        */
+       @Test void m12_cachedBody_isKeyedByMount_notSharedAcrossMountStyles() 
throws Exception {
+               var standalone1 = 
standaloneMounted(MountCacheHost.class).get("/chrome.css").run().assertStatus(200).getContent().asString();
+               var composed = 
composedMounted(MountCacheHost.class).get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
+               var standalone2 = 
standaloneMounted(MountCacheHost.class).get("/chrome.css").run().assertStatus(200).getContent().asString();
+               assertEquals("/juneau-console/assets/logo" + 
expectedCacheBuster(VALID_LOGO), emittedUrl(standalone1, "/assets/logo"));
+               assertEquals("/rest/juneau-console/assets/logo" + 
expectedCacheBuster(VALID_LOGO), emittedUrl(composed, "/assets/logo"));
+               assertEquals(standalone1, standalone2, "the standalone body 
must come back from cache unchanged");
+               assertEquals(2, MOUNT_CACHE_MIXIN.debugBuildCount(), "expected 
exactly one assembly per distinct mount");
+       }
+
        
//-----------------------------------------------------------------------------------------------------------------
        // Test helpers
        
//-----------------------------------------------------------------------------------------------------------------
 
+       /** A host composed onto an existing application mount at {@code 
/rest/*}. */
+       private static MockRestClient composedMounted(Class<?> host) {
+               return 
MockRestClient.createLax(host).servletPath("/rest").build();
+       }
+
+       /** Extracts the single {@code url("...")} value the served CSS emits 
for the given asset endpoint. */
+       private static String emittedUrl(String body, String assetPathSuffix) {
+               var m = Pattern.compile("url\\(\"([^\"]*" + 
Pattern.quote(assetPathSuffix) + "\\?v=[^\"]+)\"\\)").matcher(body);
+               assertTrue(m.find(), () -> "no emitted url() for " + 
assetPathSuffix + " in body:\n" + body);
+               return m.group(1);
+       }
+
+       /**
+        * Re-expresses a browser-absolute emitted URL as a path below the 
given mount, asserting along the way that it
+        * really does sit below it - which is the half of "the browser can 
fetch this" that a bare status assertion
+        * against a hand-written path cannot check.
+        */
+       private static String belowMount(String emittedUrl, String mount) {
+               assertTrue(emittedUrl.startsWith(mount + "/"), () -> "emitted 
url '" + emittedUrl + "' is not below the mount '" + mount + "'");
+               return emittedUrl.substring(mount.length());
+       }
+
+       /** The exact {@code ?v=<buildVersion>-<hash8>} suffix the mixin must 
still append to every emitted asset URL. */
+       private static String expectedCacheBuster(String classpathResource) 
throws IOException {
+               byte[] bytes;
+               try (var in = 
ConsoleChromeMixin_Test.class.getResourceAsStream(classpathResource)) {
+                       assertNotNull(in);
+                       bytes = in.readAllBytes();
+               }
+               var crc = new CRC32();
+               crc.update(bytes);
+               var v = 
ConsoleChromeMixin.class.getPackage().getImplementationVersion();
+               return "?v=" + (v == null ? "dev" : v) + '-' + 
String.format("%08x", crc.getValue());
+       }
+
        private static String bodyOf(MockRestClient client) throws Exception {
                return 
client.get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
        }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
index bdeb17e076..927535a929 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOpContext.java
@@ -1893,11 +1893,12 @@ public class RestOpContext extends Context implements 
Comparable<RestOpContext>
        }
 
        private UrlPathMatch matchPattern(RestSession call) {
-               UrlPathMatch pm = null;
-               for (var pp : pathMatchers.get())
-                       if (pm == null)
-                               pm = pp.match(call.getUrlPath());
-               return pm;
+               for (var pp : pathMatchers.get()) {
+                       var pm = pp.match(call.getUrlPath());
+                       if (pm != null)
+                               return pm;
+               }
+               return null;
        }
 
        /**

Reply via email to