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 f0964609d52be0bf198c8b26e3a4dff340b54af2
Author: James Bognar <[email protected]>
AuthorDate: Tue Aug 18 09:07:43 2026 -0400

    Add themeable logo/page-background assets and table theming to console 
chrome
    
    ConsoleChromeMixin gains builder-configured logo and page-background assets,
    served as versioned, content-hash-cache-busted resources from the classpath
    so applications can theme the console without url() in CSS token values
    (which CssValueGrammar rejects by design).
    
    chrome.css gains themed DataTables styling (header, zebra rows, hover,
    borders) and themed ribbon/paging control colors keyed to the --jc-* tokens,
    keeping neutral shape in juneau-views.css and color in the theme layer.
    
    Adds the test resource fixtures the mixin tests load.
---
 .../rest/server/console/ConsoleChromeMixin.java    | 174 +++++++++++++-
 .../resources/org/apache/juneau/console/chrome.css | 135 ++++++++++-
 .../server/console/ConsoleChromeMixin_Test.java    | 255 +++++++++++++++++++++
 .../src/test/resources/testfiles/console/bad.txt   |  13 ++
 .../src/test/resources/testfiles/console/logo.gif  |   1 +
 .../src/test/resources/testfiles/console/logo.jpeg |   1 +
 .../src/test/resources/testfiles/console/logo.jpg  |   1 +
 .../src/test/resources/testfiles/console/logo.svg  |   1 +
 .../src/test/resources/testfiles/console/logo.webp |  13 ++
 .../test/resources/testfiles/console/page-bg.png   |   1 +
 10 files changed, 582 insertions(+), 13 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 2767c7e318..423efb4a25 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
@@ -16,15 +16,22 @@
  */
 package org.apache.juneau.rest.server.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.nio.charset.*;
+import java.util.*;
+import java.util.concurrent.*;
 import java.util.concurrent.atomic.*;
+import java.util.zip.*;
 
+import org.apache.juneau.commons.io.*;
 import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.http.*;
 import org.apache.juneau.http.entity.*;
 import org.apache.juneau.http.header.*;
 import org.apache.juneau.http.resource.*;
+import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.server.*;
 
 /**
@@ -60,6 +67,12 @@ public class ConsoleChromeMixin {
        /** The URL path at which the chrome stylesheet is served (relative to 
the host mount). */
        public static final String CHROME_CSS_PATH = 
"/juneau-console/chrome.css";
 
+       /** The URL path at which the configured logo asset is served (relative 
to the host mount). */
+       public static final String LOGO_ASSET_PATH = 
"/juneau-console/assets/logo";
+
+       /** 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";
+
        /** Classpath location of the shipped structural stylesheet. */
        static final String CHROME_CSS_RESOURCE = 
"/org/apache/juneau/console/chrome.css";
 
@@ -69,11 +82,19 @@ public class ConsoleChromeMixin {
        /** {@code Cache-Control} header emitted for the chrome stylesheet (1 
day). */
        static final String CACHE_CONTROL = "max-age=86400, public";
 
+       /** The image file extensions a configured logo/page-background asset 
is allowed to use. */
+       private static final Set<String> ALLOWED_ASSET_EXTS = Set.of("svg", 
"png", "jpg", "jpeg", "webp", "gif");
+
        /** The shipped static chrome.css bytes, read once from the classpath 
(shared - the static file never varies by theme). */
        private static volatile String staticCss;
 
+       /** Per-resource content-hash cache for the configured 
logo/page-background assets (populated on first request). */
+       private static final Map<String,String> ASSET_HASH_CACHE = new 
ConcurrentHashMap<>();
+
        private final boolean cacheAssets;
        private final Theme theme;
+       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;
@@ -97,6 +118,8 @@ public class ConsoleChromeMixin {
        protected ConsoleChromeMixin(Builder builder) {
                this.cacheAssets = builder.cacheAssets;
                this.theme = builder.theme;
+               this.logoResource = builder.logoResource;
+               this.pageBackgroundResource = builder.pageBackgroundResource;
        }
 
        /**
@@ -124,9 +147,57 @@ public class ConsoleChromeMixin {
        )
        public HttpResource getChromeCss(RestRequest req) throws IOException {
                var body = cacheAssets ? cachedBody(req) : buildBody(req);
+               return httpResource(body, CONTENT_TYPE);
+       }
+
+       /**
+        * [GET /juneau-console/assets/logo] &mdash; serve the configured logo 
asset.
+        *
+        * @return The configured logo image as an {@link HttpResource}.
+        * @throws IOException If the configured resource could not be read.
+        */
+       @RestGet(
+               path=LOGO_ASSET_PATH,
+               summary="Configured logo image asset",
+               swagger=@OpSwagger(ignore=true)
+       )
+       public HttpResource getLogoAsset() throws IOException {
+               if (logoResource == null)
+                       throw new NotFound("No logo asset configured.");
+               return serveAsset(logoResource);
+       }
+
+       /**
+        * [GET /juneau-console/assets/page-bg] &mdash; serve the configured 
page-background asset.
+        *
+        * @return The configured page-background image as an {@link 
HttpResource}.
+        * @throws IOException If the configured resource could not be read.
+        */
+       @RestGet(
+               path=PAGE_BG_ASSET_PATH,
+               summary="Configured page-background image asset",
+               swagger=@OpSwagger(ignore=true)
+       )
+       public HttpResource getPageBackgroundAsset() throws IOException {
+               if (pageBackgroundResource == null)
+                       throw new NotFound("No page-background asset 
configured.");
+               return serveAsset(pageBackgroundResource);
+       }
+
+       /** Reads and wraps a validated, already-configured classpath resource 
as a cacheable {@link HttpResource}. */
+       private static HttpResource serveAsset(String classpathResource) throws 
IOException {
+               byte[] bytes;
+               try (var in = 
ConsoleChromeMixin.class.getResourceAsStream(classpathResource)) {
+                       bytes = IoUtils.readBytes(in);
+               }
+               return httpResource(bytes, 
MimeTypeDetector.DEFAULT.getContentType(classpathResource));
+       }
+
+       /** Wraps pre-computed bytes as a cacheable {@link HttpResource} 
carrying the given content type. */
+       private static HttpResource httpResource(byte[] bytes, String 
contentType) {
                return HttpResourceBean.of(
-                       ByteArrayBody.of(body, CONTENT_TYPE),
-                       CollectionUtils.list(ContentType.of(CONTENT_TYPE), 
CacheControl.of(CACHE_CONTROL))
+                       ByteArrayBody.of(bytes, contentType),
+                       CollectionUtils.list(ContentType.of(contentType), 
CacheControl.of(CACHE_CONTROL))
                );
        }
 
@@ -145,7 +216,13 @@ public class ConsoleChromeMixin {
                return b;
        }
 
-       /** Builds the response body: the static structural CSS, then 
Theme.OPEN's block, then (if different) the active theme's override block. */
+       /**
+        * Builds the response body: the static structural CSS, then 
Theme.OPEN's block, then (if different) the active
+        * theme's override block, then (if configured) the 
logo/page-background asset override rules. Each override
+        * rule's {@code ?v=<buildVersion>-<hash8>} cache-buster is 
content-sensitive (see {@link #assetContentHash},
+        * mirroring {@code ViewsMixin}) so a {@code -SNAPSHOT} rebuild of the 
configured asset busts the browser cache
+        * without relying on {@code buildVersion} (stable across dev rebuilds) 
alone.
+        */
        private byte[] buildBody(RestRequest req) throws IOException {
                buildCount.incrementAndGet();
                var sb = new StringBuilder(staticCss());
@@ -153,9 +230,47 @@ public class ConsoleChromeMixin {
                var active = resolveActiveTheme(req);
                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)
+                               
.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)
+                               
.append("?v=").append(buildVersion()).append('-').append(assetContentHash(logoResource))
+                               .append("\");}");
                return sb.toString().getBytes(StandardCharsets.UTF_8);
        }
 
+       /**
+        * 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).
+        */
+       private static String buildVersion() {
+               var v = 
ConsoleChromeMixin.class.getPackage().getImplementationVersion();
+               return v == null ? "dev" : v;  // HTT: the non-null branch only 
fires when running from a packaged jar with a manifest Implementation-Version - 
unreachable when tests run against unpackaged target/classes.
+       }
+
+       /** Computes (and caches) the given classpath resource's 8-hex-char 
content hash, read once per resource. */
+       private static String assetContentHash(String classpathResource) {
+               return ASSET_HASH_CACHE.computeIfAbsent(classpathResource, 
ConsoleChromeMixin::readAndHash);
+       }
+
+       /** Reads a validated, already-configured classpath resource and hashes 
its bytes. */
+       private static String readAndHash(String classpathResource) {
+               try (var in = 
ConsoleChromeMixin.class.getResourceAsStream(classpathResource)) {
+                       return hash8(IoUtils.readBytes(in));
+               } catch (IOException e) {  // HTT: unreachable - 
validateAssetResource already confirmed the resource exists.
+                       throw new UncheckedIOException(e);
+               }
+       }
+
+       /** Formats a CRC32 checksum of {@code bytes} as a zero-padded 
8-hex-char content hash. */
+       private static String hash8(byte[] bytes) {
+               var crc = new CRC32();
+               crc.update(bytes);
+               return String.format("%08x", crc.getValue());
+       }
+
        /**
         * Test-only diagnostic: the number of times this instance has 
(re)assembled its response body.
         *
@@ -205,6 +320,8 @@ public class ConsoleChromeMixin {
        public static class Builder {
                boolean cacheAssets = true;
                Theme theme;
+               String logoResource;
+               String pageBackgroundResource;
 
                /**
                 * Whether to cache the assembled response body after the first 
request (default <jk>true</jk>).
@@ -228,6 +345,39 @@ public class ConsoleChromeMixin {
                        return this;
                }
 
+               /**
+                * Configures a themeable logo image, served at {@link 
#LOGO_ASSET_PATH} and overriding the default
+                * {@code .jc-logo} background image in the emitted {@code 
chrome.css}.
+                *
+                * @param value
+        *      An app-owned, classpath-root-absolute resource path (e.g. 
{@code "/static/img/oakleaf.svg"}). Must exist on
+        *      the classpath, contain no {@code ..} path segment or {@code %} 
character, and end in one of {@code .svg}/
+        *      {@code .png}/{@code .jpg}/{@code .jpeg}/{@code .webp}/{@code 
.gif}.
+                * @return This object.
+                * @throws IllegalArgumentException If {@code value} is 
<jk>null</jk>, empty, traversal-shaped, has an
+                *      unrecognized extension, or does not resolve to an 
existing classpath resource.
+                */
+               public Builder logo(String value) {
+                       this.logoResource = validateAssetResource(value, 
"logo");
+                       return this;
+               }
+
+               /**
+                * Configures a themeable page-background image, served at 
{@link #PAGE_BG_ASSET_PATH} and layered over the
+                * active theme's {@code --jc-page-bg} gradient in the emitted 
{@code chrome.css}.
+                *
+                * @param value
+                *      An app-owned, classpath-root-absolute resource path 
(e.g. {@code "/static/img/topo-bg.png"}). Same
+                *      validation as {@link #logo(String)}.
+                * @return This object.
+                * @throws IllegalArgumentException If {@code value} is 
<jk>null</jk>, empty, traversal-shaped, has an
+                *      unrecognized extension, or does not resolve to an 
existing classpath resource.
+                */
+               public Builder pageBackgroundImage(String value) {
+                       this.pageBackgroundResource = 
validateAssetResource(value, "pageBackgroundImage");
+                       return this;
+               }
+
                /**
                 * Builds the mixin.
                 *
@@ -237,4 +387,22 @@ public class ConsoleChromeMixin {
                        return new ConsoleChromeMixin(this);
                }
        }
+
+       /**
+        * Fail-closed validation for a configured asset's classpath resource 
path: reject <jk>null</jk>/empty, reject
+        * any traversal-shaped path (containing {@code ..} or a {@code %} 
URI-encoding escape, matching
+        * {@code BasicFileFinder.isInvalidPath}), reject an extension outside 
the image allowlist, and reject a path
+        * that does not resolve to an existing classpath resource.
+        */
+       private static String validateAssetResource(String value, String 
paramName) {
+               if (value == null || value.isEmpty())
+                       throw iaex("'%s' must not be null or empty.", 
paramName);
+               if (value.contains("..") || value.contains("%"))
+                       throw iaex("'%s' must not contain '..' or '%%' (path 
traversal): '%s'.", paramName, value);
+               if (! 
ALLOWED_ASSET_EXTS.contains(FileUtils.getFileExtension(value).toLowerCase(Locale.ROOT)))
+                       throw iaex("'%s' must end in one of 
.svg/.png/.jpg/.jpeg/.webp/.gif: '%s'.", paramName, value);
+               if (ConsoleChromeMixin.class.getResource(value) == null)
+                       throw iaex("'%s' classpath resource not found: '%s'.", 
paramName, value);
+               return value;
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
 
b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
index d1390e9ff6..737c09b967 100644
--- 
a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
@@ -71,10 +71,8 @@ a:hover { text-decoration: underline; }
 .jc-header {
   display: flex;
   align-items: center;
-  height: 68px;
+  height: 56px;
   background-color: var(--jc-white);
-  border-bottom: 1px solid var(--jc-border);
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
   position: sticky;
   top: 0;
   z-index: 20;
@@ -88,9 +86,9 @@ a:hover { text-decoration: underline; }
 }
 
 .jc-logo {
-  width: 48px;
-  height: 48px;
-  margin: 10px 20px;
+  width: 40px;
+  height: 40px;
+  margin: 8px 16px;
   flex: 0 0 auto;
   border-radius: var(--jc-radius);
   background-color: var(--jc-white);
@@ -165,7 +163,7 @@ a:hover { text-decoration: underline; }
   border-bottom: 3px solid var(--jc-accent);
   padding: 0 16px;
   position: sticky;
-  top: 68px;
+  top: 56px;
   z-index: 19;
 }
 
@@ -173,10 +171,10 @@ a:hover { text-decoration: underline; }
   display: flex;
   align-items: center;
   font-size: 0.8125rem;
-  font-weight: 600;
+  font-weight: 400;
   color: var(--jc-text-soft);
-  padding: 8px 10px;
-  margin-right: 2px;
+  padding: 9px 12px;
+  margin-right: 0;
   border-top: 3px solid transparent;
   border-bottom: none;
   cursor: pointer;
@@ -386,6 +384,123 @@ table.jc-table tbody tr:nth-child(odd) { 
background-color: #fbfbfc; }
 table.jc-table tbody tr:hover { background-color: var(--jc-accent-wash); }
 table.jc-table tbody tr:last-child td { border-bottom: none; }
 
+/* ==========================================================================
+   DataTables table visual parity (IRS reference: compact rows, subtle zebra
+   striping, row hover, light header) - juneau-rest-server-views' neutral
+   `table[data-juneau-view]` / `table.dataTable` selectors (juneau-views.css)
+   get their font-family/color/background-color/border-color HERE, via
+   --jc-* tokens - the same base-shape-in-views / colors-in-chrome split as
+   the ".tag"/ribbon reconciliation above.  Child-combinator selectors mirror
+   the vendored dataTables.dataTables.css structure so specificity TIES (not
+   loses to) it; this file is served LAST in every juneau-views page's
+   <head>, so ties resolve in our favor by cascade order.
+
+   Covers BOTH DataTables stripe/hover generations: DT1.x's `tr.odd`/
+   `tr.even` row classes, AND DT2.x's - this app's table carries no
+   "stripe"/"hover"/"display" convenience class (see juneau-views.js), so
+   DT2.x's own box-shadow-based nth-child stripe/hover never actually fires;
+   the bare `tbody tr:nth-child(odd/even)`/`:hover` rules below are what
+   actually paints every row today.  The `table.dataTable.stripe`/`.hover`/
+   `.display` forms are ALSO covered for any future consumer that opts into
+   those convenience classes, with the vendored --dt-row-stripe/--dt-row-hover
+   CSS variables neutralized (white, near-zero alpha) so that DataTables'
+   own inset box-shadow overlay can never visibly fight these backgrounds.
+   ========================================================================== 
*/
+
+table[data-juneau-view],
+table.dataTable {
+  font-family: var(--jc-font);
+  color: var(--jc-text);
+  border-bottom-color: var(--jc-border);   /* themes the table's own outer 
bottom border */
+  --dt-row-stripe: 255, 255, 255;
+  --dt-row-hover: 255, 255, 255;
+}
+
+table[data-juneau-view] > thead > tr > th,
+table[data-juneau-view] > thead > tr > td,
+table.dataTable > thead > tr > th,
+table.dataTable > thead > tr > td {
+  background-color: var(--jc-chrome-bg);
+  color: var(--jc-text-soft);
+  border-bottom-color: var(--jc-border);
+}
+
+table[data-juneau-view] > tbody > tr > th,
+table[data-juneau-view] > tbody > tr > td,
+table.dataTable > tbody > tr > th,
+table.dataTable > tbody > tr > td {
+  border-bottom-color: var(--jc-border);
+}
+
+/* Zebra striping - bare nth-child (the mechanism actually in effect today, 
see comment above), tr.odd/tr.even
+   (DT1.x), and table.dataTable.stripe/.display nth-child (DT2.x's own 
convenience-class opt-in form). */
+table[data-juneau-view] > tbody > tr:nth-child(odd),
+table[data-juneau-view] > tbody > tr.odd,
+table.dataTable > tbody > tr:nth-child(odd),
+table.dataTable > tbody > tr.odd,
+table.dataTable.stripe > tbody > tr:nth-child(odd),
+table.dataTable.display > tbody > tr:nth-child(odd) {
+  background-color: var(--jc-white);
+}
+
+table[data-juneau-view] > tbody > tr:nth-child(even),
+table[data-juneau-view] > tbody > tr.even,
+table.dataTable > tbody > tr:nth-child(even),
+table.dataTable > tbody > tr.even,
+table.dataTable.stripe > tbody > tr:nth-child(even),
+table.dataTable.display > tbody > tr:nth-child(even) {
+  background-color: var(--jc-card-bg);
+}
+
+/* Row hover - bare :hover (the mechanism actually in effect today) plus the 
.hover/.display convenience-class
+   form; wins over the zebra background-color above by source order (this rule 
is declared after it). */
+table[data-juneau-view] > tbody > tr:hover,
+table.dataTable > tbody > tr:hover,
+table.dataTable.hover > tbody > tr:hover,
+table.dataTable.display > tbody > tr:hover {
+  background-color: var(--jc-accent-wash);
+}
+
+/* ==========================================================================
+   DataTables ribbon visual parity (juneau-rest-server-views' ".juneau-view-*"
+   classes) - additive themed accent layer only.  -views ships the neutral
+   base shape (border/color via currentColor); this file themes the SAME
+   class names with --jc-accent/--jc-accent-wash, sink-property-allowlist
+   compliant (color/background-color only - see the file header comment).
+   ========================================================================== 
*/
+
+/* Base thin-neutral-border shape (IRS parity item 1) - -views ships only a 
currentColor-fallback border, which
+   inherits the body's near-black text color instead of IRS's subtle gray; 
themed here with --jc-border, plus a
+   white face so a button reads as a distinct control against the card's 
tinted background. */
+.juneau-view-ribbon-btn { border-color: var(--jc-border); background-color: 
var(--jc-white); color: var(--jc-text-soft); }
+.juneau-view-ribbon-btn:hover { background-color: var(--jc-accent-wash); 
color: var(--jc-accent); }
+.juneau-view-ribbon-btn:active { background-color: var(--jc-accent); color: 
var(--jc-white); }
+
+/* Themed accent for an ACTIVE toggle (option/optionGroup member button's 
aria-pressed="true" - see -views'
+   neutral border-width rule for this same attribute selector). Without this, 
a persisted, still-active filter
+   (e.g. a column-scoped "option" toggle whose state survived a browser 
refresh) is visually indistinguishable
+   from an unpressed button - clicking it once and never again can silently 
keep filtering out every row.  Also
+   matches the blue "active" export button in the IRS reference ribbon (item 
1). */
+.juneau-view-ribbon-btn[aria-pressed="true"] { background-color: 
var(--jc-accent); color: var(--jc-white); border-color: var(--jc-accent); }
+
+/* Unified paging ribbon (nav + page-size menu) - same 
thin-neutral-border/white-face treatment as the ribbon
+   buttons. */
+.juneau-view-pagingpill { border-color: var(--jc-border); background-color: 
var(--jc-white); }
+.juneau-view-pagingpill-btn { border-color: var(--jc-border); color: 
var(--jc-text-soft); }
+.juneau-view-pagingpill-btn:hover:not(:disabled) { background-color: 
var(--jc-accent-wash); color: var(--jc-accent); }
+.juneau-view-pagingpill-menuwrap { border-color: var(--jc-border); }
+.juneau-view-pagingpill-menubtn { color: var(--jc-text-soft); }
+.juneau-view-pagingpill-menubtn:hover,
+.juneau-view-pagingpill-menubtn[aria-expanded="true"] { background-color: 
var(--jc-accent-wash); color: var(--jc-accent); }
+.juneau-view-pagingpill-menu { border-color: var(--jc-border); 
background-color: var(--jc-white); }
+.juneau-view-pagingpill-menu-option:hover,
+.juneau-view-pagingpill-menu-option:focus { background-color: 
var(--jc-accent-wash); color: var(--jc-accent); }
+.juneau-view-pagingpill-menu-option[aria-selected="true"] { color: 
var(--jc-accent); }
+
+/* Per-column search row inputs (IRS parity item 4) - same thin-neutral-border 
treatment, themed accent on focus. */
+.juneau-view-columnsearch-input { border-color: var(--jc-border); 
background-color: var(--jc-white); color: var(--jc-text); }
+.juneau-view-columnsearch-input:focus { border-color: var(--jc-accent); }
+
 /* ==========================================================================
    Footer
    ========================================================================== 
*/
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 1921da6dc4..d1610f82f6 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
@@ -300,6 +300,249 @@ class ConsoleChromeMixin_Test extends TestBase {
                assertFalse(bodyY.contains("--jc-accent:#aa0000;"), () -> 
"mount Y leaked mount X's theme, body:\n" + bodyY);
        }
 
+       
//-----------------------------------------------------------------------------------------------------------------
+       // g) Builder validation: logo(...) / pageBackgroundImage(...)
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       private static final String VALID_LOGO = "/testfiles/console/logo.svg";
+       private static final String VALID_PAGE_BG = 
"/testfiles/console/page-bg.png";
+
+       @Test void g01_logo_null_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().logo(null));
+       }
+
+       @Test void g02_logo_empty_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().logo(""));
+       }
+
+       @Test void g03_logo_pathTraversalSegment_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().logo("/testfiles/console/../../../etc/passwd"));
+       }
+
+       @Test void g04_logo_unallowlistedExtension_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().logo("/testfiles/console/bad.txt"));
+       }
+
+       @Test void g05_logo_resourceDoesNotExist_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().logo("/testfiles/console/nope.svg"));
+       }
+
+       @Test void g06_logo_validClasspathResource_accepted() {
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().logo(VALID_LOGO).build());
+       }
+
+       @Test void g07_pageBackgroundImage_null_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().pageBackgroundImage(null));
+       }
+
+       @Test void g08_pageBackgroundImage_empty_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().pageBackgroundImage(""));
+       }
+
+       @Test void g09_pageBackgroundImage_pathTraversalSegment_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().pageBackgroundImage("/testfiles/console/../../../etc/passwd"));
+       }
+
+       @Test void g10_pageBackgroundImage_unallowlistedExtension_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().pageBackgroundImage("/testfiles/console/bad.txt"));
+       }
+
+       @Test void g11_pageBackgroundImage_resourceDoesNotExist_rejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
ConsoleChromeMixin.create().pageBackgroundImage("/testfiles/console/nope.png"));
+       }
+
+       @Test void g12_pageBackgroundImage_validClasspathResource_accepted() {
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().pageBackgroundImage(VALID_PAGE_BG).build());
+       }
+
+       @Test void g13_allAllowlistedExtensions_accepted() {
+               // One fixture per allowlisted extension, content irrelevant - 
only the extension drives validation/content-type.
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().logo("/testfiles/console/logo.svg").build());
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().logo("/testfiles/console/logo.jpg").build());
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().logo("/testfiles/console/logo.jpeg").build());
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().logo("/testfiles/console/logo.webp").build());
+               assertDoesNotThrow(() -> 
ConsoleChromeMixin.create().logo("/testfiles/console/logo.gif").build());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // h) Asset serving: /juneau-console/assets/logo, 
/juneau-console/assets/page-bg
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(mixins=ConsoleChromeMixin.class)
+       public static class NoAssetsHost extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Rest(mixins=ConsoleChromeMixin.class)
+       public static class AssetsHost extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public ConsoleChromeMixin console() {
+                       return 
ConsoleChromeMixin.create().logo(VALID_LOGO).pageBackgroundImage(VALID_PAGE_BG).build();
+               }
+       }
+
+       @Test void h01_logoAsset_unconfigured_is404() throws Exception {
+               
MockRestClient.buildLax(NoAssetsHost.class).get(ConsoleChromeMixin.LOGO_ASSET_PATH).run().assertStatus(404);
+       }
+
+       @Test void h02_pageBgAsset_unconfigured_is404() throws Exception {
+               
MockRestClient.buildLax(NoAssetsHost.class).get(ConsoleChromeMixin.PAGE_BG_ASSET_PATH).run().assertStatus(404);
+       }
+
+       @Test void 
h03_logoAsset_configured_servesBytesWithSvgContentTypeAndCacheControl() throws 
Exception {
+               assertAssetServed("/testfiles/console/logo.svg", 
ConsoleChromeMixin.LOGO_ASSET_PATH, "image/svg+xml");
+       }
+
+       @Test void 
h04_pageBgAsset_configured_servesBytesWithPngContentTypeAndCacheControl() 
throws Exception {
+               assertAssetServed("/testfiles/console/page-bg.png", 
ConsoleChromeMixin.PAGE_BG_ASSET_PATH, "image/png");
+       }
+
+       @Test void h05_logoAsset_arbitraryVersionQueryString_stillServes200() 
throws Exception {
+               // The `?v=...` cache-buster is consumed by the browser's cache 
key, not by mixin routing - any value
+               // (or none) must still resolve to the same configured asset.
+               
MockRestClient.buildLax(AssetsHost.class).get(ConsoleChromeMixin.LOGO_ASSET_PATH
 + "?v=whatever").run().assertStatus(200);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // i) chrome.css composition: logo/page-bg override rules, with 
versioned URLs
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(mixins=ConsoleChromeMixin.class)
+       public static class LogoOnlyHost extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public ConsoleChromeMixin console() { return 
ConsoleChromeMixin.create().logo(VALID_LOGO).build(); }
+       }
+
+       @Rest(mixins=ConsoleChromeMixin.class)
+       public static class PageBgOnlyHost extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @Bean public ConsoleChromeMixin console() { return 
ConsoleChromeMixin.create().pageBackgroundImage(VALID_PAGE_BG).build(); }
+       }
+
+       @Test void i01_noAssetsConfigured_chromeCssUnaffected() throws 
Exception {
+               var body = bodyOf(MockRestClient.buildLax(NoAssetsHost.class));
+               assertFalse(body.contains(ConsoleChromeMixin.LOGO_ASSET_PATH));
+               
assertFalse(body.contains(ConsoleChromeMixin.PAGE_BG_ASSET_PATH));
+       }
+
+       @Test void i02_logoConfigured_chromeCssOverridesJcLogoBackgroundImage() 
throws Exception {
+               var body = bodyOf(MockRestClient.buildLax(AssetsHost.class));
+               assertTrue(body.contains(".jc-logo{background-image:url(\"" + 
ConsoleChromeMixin.LOGO_ASSET_PATH + "?v="),
+                       () -> "missing logo override rule, body:\n" + body);
+       }
+
+       @Test void i03_pageBgConfigured_chromeCssLayersImageOverGradientToken() 
throws Exception {
+               var body = bodyOf(MockRestClient.buildLax(AssetsHost.class));
+               assertTrue(body.contains("url(\"" + 
ConsoleChromeMixin.PAGE_BG_ASSET_PATH + "?v="), () -> "missing page-bg url(), 
body:\n" + body);
+               assertTrue(body.contains("), var(--jc-page-bg);"), () -> 
"missing gradient-token fallback layer, body:\n" + body);
+       }
+
+       @Test void 
i04_versionedQueryString_matchesPackageImplementationVersionPlusContentHash() 
throws Exception {
+               var body = bodyOf(MockRestClient.buildLax(AssetsHost.class));
+               var v = 
ConsoleChromeMixin.class.getPackage().getImplementationVersion();
+               var expectedPrefix = "?v=" + (v == null ? "dev" : v) + "-";
+               var m = 
Pattern.compile(Pattern.quote(ConsoleChromeMixin.LOGO_ASSET_PATH) + 
Pattern.quote(expectedPrefix) + "([0-9a-f]{8})\"").matcher(body);
+               assertTrue(m.find(), () -> "expected version+content-hash 
cache-buster, body:\n" + body);
+       }
+
+       @Test void 
i07_contentHashCacheBuster_isStableAcrossRequests_andDiffersBetweenLogoAndPageBg()
 throws Exception {
+               // The content-hash cache-buster (Task 1) is computed from each 
asset's own bytes, cached once - it must be
+               // stable across requests (not recomputed per-request) and must 
differ between the two distinct fixture files.
+               var body1 = bodyOf(MockRestClient.buildLax(AssetsHost.class));
+               var body2 = bodyOf(MockRestClient.buildLax(AssetsHost.class));
+               var logoHash = extractHash(body1, 
ConsoleChromeMixin.LOGO_ASSET_PATH);
+               var pageBgHash = extractHash(body1, 
ConsoleChromeMixin.PAGE_BG_ASSET_PATH);
+               assertEquals(logoHash, extractHash(body2, 
ConsoleChromeMixin.LOGO_ASSET_PATH), "hash must be stable across requests");
+               assertNotEquals(logoHash, pageBgHash, "distinct fixture assets 
must not collide on their content hash");
+       }
+
+       private static String extractHash(String body, String assetPath) {
+               var m = Pattern.compile(Pattern.quote(assetPath) + 
"\\?v=[^-\"]+-([0-9a-f]{8})\"").matcher(body);
+               assertTrue(m.find(), () -> "no versioned+hashed url for " + 
assetPath + " in body:\n" + body);
+               return m.group(1);
+       }
+
+       @Test void 
i05_onlyLogoConfigured_pageBgOverrideRuleAbsent_andPageBgAssetStill404() throws 
Exception {
+               var c = MockRestClient.buildLax(LogoOnlyHost.class);
+               var body = bodyOf(c);
+               assertTrue(body.contains(ConsoleChromeMixin.LOGO_ASSET_PATH));
+               
assertFalse(body.contains(ConsoleChromeMixin.PAGE_BG_ASSET_PATH));
+               
c.get(ConsoleChromeMixin.PAGE_BG_ASSET_PATH).run().assertStatus(404);
+       }
+
+       @Test void 
i06_onlyPageBgConfigured_logoOverrideRuleAbsent_andLogoAssetStill404() throws 
Exception {
+               var c = MockRestClient.buildLax(PageBgOnlyHost.class);
+               var body = bodyOf(c);
+               
assertTrue(body.contains(ConsoleChromeMixin.PAGE_BG_ASSET_PATH));
+               assertFalse(body.contains(ConsoleChromeMixin.LOGO_ASSET_PATH));
+               
c.get(ConsoleChromeMixin.LOGO_ASSET_PATH).run().assertStatus(404);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // j) Security regression: Theme/CssValueGrammar untouched by this 
feature
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void 
j01_assembledResponseWithBothAssetsConfigured_passesChromeCssScanner() throws 
Exception {
+               var body = bodyOf(MockRestClient.buildLax(AssetsHost.class));
+               assertEquals(List.of(), ChromeCssScanner.scan(body), () -> 
"violations against assembled body:\n" + body);
+       }
+
+       @Test void 
j02_themeTokenPath_stillRejectsUrlProduction_evenWithAssetsFeaturePresent() {
+               assertThrows(IllegalArgumentException.class,
+                       () -> 
ConsoleChromeMixin.create().theme(Theme.create("x").token("--jc-page-bg", 
"url(https://evil)").build()));
+       }
+
+       @Test void j03_themeOpenTokenCount_pinned_unaffectedByAssetsFeature() {
+               // A0 must not add a --jc-logo or --jc-page-bg-image token - 
the logo/page-bg mechanism is deliberately
+               // NOT part of the Theme token model (finding 4 of the design 
doc). If this count ever changes, it must be
+               // a DIFFERENT, deliberate change to Theme.OPEN - not a side 
effect of the asset feature.
+               assertEquals(32, Theme.OPEN.getTokens().size());
+               assertFalse(Theme.OPEN.getTokens().containsKey("--jc-logo"));
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // k) DataTables table visual parity (IRS reference: zebra striping, 
row hover, themed header)
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void k01_chromeCss_themesDataTableZebraStriping_bothGenerations() 
throws Exception {
+               var css = readChromeCss();
+               // DT2.x bare markup (no "stripe" convenience class - what this 
app's tables actually render today).
+               assertTrue(css.contains("table.dataTable > tbody > 
tr:nth-child(odd)"), () -> "missing bare odd-row rule, css:\n" + css);
+               assertTrue(css.contains("table.dataTable > tbody > 
tr:nth-child(even)"), () -> "missing bare even-row rule, css:\n" + css);
+               // DT1.x row classes.
+               assertTrue(css.contains("table.dataTable > tbody > tr.odd"), () 
-> "missing tr.odd rule, css:\n" + css);
+               assertTrue(css.contains("table.dataTable > tbody > tr.even"), 
() -> "missing tr.even rule, css:\n" + css);
+               // DT2.x "stripe"/"display" convenience-class opt-in form.
+               assertTrue(css.contains("table.dataTable.stripe > tbody > 
tr:nth-child(odd)"), () -> "missing .stripe odd-row rule, css:\n" + css);
+               assertTrue(css.contains("table.dataTable.stripe > tbody > 
tr:nth-child(even)"), () -> "missing .stripe even-row rule, css:\n" + css);
+       }
+
+       @Test void k02_chromeCss_themesDataTableRowHover_bothGenerations() 
throws Exception {
+               var css = readChromeCss();
+               assertTrue(css.contains("table.dataTable > tbody > tr:hover"), 
() -> "missing bare row-hover rule, css:\n" + css);
+               assertTrue(css.contains("table.dataTable.hover > tbody > 
tr:hover"), () -> "missing .hover row-hover rule, css:\n" + css);
+       }
+
+       @Test void k03_chromeCss_neutralizesVendoredStripeHoverCssVariables() 
throws Exception {
+               var css = readChromeCss();
+               assertTrue(css.contains("--dt-row-stripe:"), () -> "missing 
--dt-row-stripe neutralization, css:\n" + css);
+               assertTrue(css.contains("--dt-row-hover:"), () -> "missing 
--dt-row-hover neutralization, css:\n" + css);
+       }
+
+       @Test void k04_chromeCss_themesDataTableHeaderAndFont() throws 
Exception {
+               var css = readChromeCss();
+               assertTrue(css.contains("table.dataTable {"), () -> "missing 
table.dataTable base rule, css:\n" + css);
+               assertTrue(css.contains("font-family: var(--jc-font);"), () -> 
"missing themed font-family, css:\n" + css);
+               assertTrue(css.contains("table.dataTable > thead > tr > th"), 
() -> "missing themed header rule, css:\n" + css);
+       }
+
+       private static String readChromeCss() throws IOException {
+               try (var in = 
ConsoleChromeMixin_Test.class.getResourceAsStream("/org/apache/juneau/console/chrome.css"))
 {
+                       assertNotNull(in);
+                       return new String(in.readAllBytes(), 
StandardCharsets.UTF_8);
+               }
+       }
+
        
//-----------------------------------------------------------------------------------------------------------------
        // Test helpers
        
//-----------------------------------------------------------------------------------------------------------------
@@ -308,6 +551,18 @@ class ConsoleChromeMixin_Test extends TestBase {
                return 
client.get(ConsoleChromeMixin.CHROME_CSS_PATH).run().assertStatus(200).getContent().asString();
        }
 
+       private static void assertAssetServed(String resourcePath, String 
assetPath, String expectedContentType) throws Exception {
+               byte[] expected;
+               try (var in = 
ConsoleChromeMixin_Test.class.getResourceAsStream(resourcePath)) {
+                       expected = in.readAllBytes();
+               }
+               var res = 
MockRestClient.buildLax(AssetsHost.class).get(assetPath).run()
+                       .assertStatus(200)
+                       
.assertHeader("Content-Type").isContains(expectedContentType)
+                       .assertHeader("Cache-Control").isContains("max-age");
+               assertArrayEquals(expected, res.getContent().asBytes());
+       }
+
        private static int countRootBlocks(String body) {
                var m = Pattern.compile(":root\\{").matcher(body);
                var n = 0;
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/bad.txt
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/bad.txt
new file mode 100644
index 0000000000..29417a1af3
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/bad.txt
@@ -0,0 +1,13 @@
+***************************************************************************************************************************
+* Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements.  See the NOTICE file *
+* distributed with this work for additional information regarding copyright 
ownership.  The ASF licenses this file        *
+* to you 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.   
                                           *
+***************************************************************************************************************************
+not an image
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.gif
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.gif
new file mode 100644
index 0000000000..ae999c29c9
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.gif
@@ -0,0 +1 @@
+placeholder-not-a-real-gif
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.jpeg
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.jpeg
new file mode 100644
index 0000000000..5bffc6ffe1
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.jpeg
@@ -0,0 +1 @@
+placeholder-not-a-real-jpeg
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.jpg
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.jpg
new file mode 100644
index 0000000000..03c9ca9c74
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.jpg
@@ -0,0 +1 @@
+placeholder-not-a-real-jpg
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.svg
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.svg
new file mode 100644
index 0000000000..7c8094fb60
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 10 10"><rect width="10" 
height="10" fill="#1589EE"/></svg>
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.webp
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.webp
new file mode 100644
index 0000000000..5f6631b859
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/logo.webp
@@ -0,0 +1,13 @@
+***************************************************************************************************************************
+* Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements.  See the NOTICE file *
+* distributed with this work for additional information regarding copyright 
ownership.  The ASF licenses this file        *
+* to you 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.   
                                           *
+***************************************************************************************************************************
+placeholder-not-a-real-webp
diff --git 
a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/page-bg.png
 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/page-bg.png
new file mode 100644
index 0000000000..9906aba1c8
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/testfiles/console/page-bg.png
@@ -0,0 +1 @@
+placeholder-not-a-real-png

Reply via email to