From 5383f03fb3a8a5bf2074547f38a16f40d09a3b61 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Tue, 24 Feb 2026 08:00:31 +0100 Subject: [PATCH 01/25] increase version for next increment --- .../bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevbackoffice/external-dependencies.xml | 2 +- .../bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevenvconfig/external-dependencies.xml | 2 +- .../hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml | 2 +- .../bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml | 2 +- .../bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevreporting/external-dependencies.xml | 2 +- .../hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevtoolkit/external-dependencies.xml | 2 +- sonar-project.properties | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml index e12900c..9f08e0a 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml index e203eb3..7e8609b 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevbackoffice - 5.0.0 + 5.1.0-snapshot jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml index 5b4fe64..04b27b7 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml @@ -1,7 +1,7 @@ + name="cxdevenvconfig" version="5.1.0-snapshot" usemaven="true"> diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml index 11d6e5e..09e3337 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevenvconfig - 5.0.0 + 5.1.0-snapshot jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml index d392fa6..d7f00b5 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml index 5513ac1..82ac552 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevproxy - 5.0.0 + 5.1.0-snapshot jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml index ea5f96a..f45050c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml index 0e6bb88..4b826ae 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevreporting - 5.0.0 + 5.1.0-snapshot jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml index 5b20292..ff3da32 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml @@ -1,7 +1,7 @@ + name="cxdevtoolkit" version="5.1.0-snapshot" usemaven="true"> diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml index 923b854..50ca88d 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevtoolkit - 5.0.0 + 5.1.0-snapshot jar diff --git a/sonar-project.properties b/sonar-project.properties index bd02abf..addbe04 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.organization=cxdevtools # This is the name and version displayed in the SonarCloud UI. sonar.projectName=cxdevtools-workspace -sonar.projectVersion=5.0.0 +sonar.projectVersion=5.1.0-snapshot # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. sonar.sources=core-customize/hybris/bin/custom/cxdevtools From 8493a0d03b0f673996d27d10de70652637106a5f Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Fri, 27 Feb 2026 14:47:45 +0100 Subject: [PATCH 02/25] additional conditions and handlers for cxdevproxy --- .../static-content/{index.html => login.html} | 36 ++++++---- .../condition/CookieExistsCondition.java | 25 +++++++ .../condition/PathAntMatcherCondition.java | 28 ++++++++ .../proxy/condition/PathRegexCondition.java | 31 ++++++++ .../proxy/handler/CorsInjectorHandler.java | 61 ++++++++++++++++ .../proxy/handler/NetworkDelayHandler.java | 71 +++++++++++++++++++ .../proxy/handler/StaticResponseHandler.java | 39 ++++++++++ 7 files changed, 279 insertions(+), 12 deletions(-) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/{index.html => login.html} (78%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/index.html b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html similarity index 78% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/index.html rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html index 84c136d..f247324 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/index.html +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html @@ -57,16 +57,26 @@

productmanager

PIM & Backoffice Access

-

ðŸ›ï¸ Customers (Storefront)

+

ðŸ›ï¸ Customers (B2C Storefront)

-
-

besucher

Max Besucher

+
+

visitor

B2C Visitor

-
-

besteller

Moritz Besteller

+
+

customer

B2C Customer

-
-

training

Franz Training

+
+ +

ðŸ›ï¸ Customers (B2B Storefront)

+
+
+

b2bcustomer

B2B Customer

+
+
+

b2bapprover

B2B Approver

+
+
+

b2bmanager

B2B Manager

@@ -109,10 +119,10 @@

training

Franz Training

// Generate Deeplinks dynamically based on current host const baseUrl = window.location.protocol + "//" + window.location.host; const links = [ - { name: "HAC", path: "/hac/" }, - { name: "Backoffice", path: "/backoffice/" }, - { name: "SmartEdit", path: "/smartedit/" }, - { name: "Storefront", path: "/" } + { type: "employee", name: "HAC", path: "/hac/" }, + { type: "employee", name: "Backoffice", path: "/backoffice/" }, + { type: "employee", name: "SmartEdit", path: "/smartedit/" }, + { type: "customer", name: "Storefront", path: "/" } ]; const linksContainer = document.getElementById('links-container'); @@ -121,7 +131,9 @@

training

Franz Training

a.href = baseUrl + link.path; a.className = 'link-btn'; a.innerText = link.name; - linksContainer.appendChild(a); + if (link.type == currentUserType) { + linksContainer.appendChild(a); + } }); document.getElementById('deeplinks-section').style.display = 'block'; } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java new file mode 100644 index 0000000..a6a16ca --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java @@ -0,0 +1,25 @@ +package me.cxdev.commerce.proxy.condition; + +import io.undertow.server.HttpServerExchange; + +import org.apache.commons.lang3.StringUtils; + +/** + * Condition that matches if a specific cookie is present in the request. + * Useful for routing based on feature toggles, A/B tests, or specific mock users. + */ +public class CookieExistsCondition implements ExchangeCondition { + private String cookieName; + + @Override + public boolean matches(HttpServerExchange exchange) { + if (StringUtils.isBlank(cookieName)) { + return false; + } + return exchange.getRequestCookie(cookieName) != null; + } + + public void setCookieName(String cookieName) { + this.cookieName = cookieName; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java new file mode 100644 index 0000000..030c603 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java @@ -0,0 +1,28 @@ +package me.cxdev.commerce.proxy.condition; + +import io.undertow.server.HttpServerExchange; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.AntPathMatcher; + +/** + * Condition that matches the request path using Spring's AntPathMatcher. + * Highly useful for matching paths with standard wildcards. + */ +public class PathAntMatcherCondition implements ExchangeCondition { + private String pattern; + private final AntPathMatcher antPathMatcher = new AntPathMatcher(); + + @Override + public boolean matches(HttpServerExchange exchange) { + if (StringUtils.isBlank(pattern)) { + return false; + } + // Match the resolved path against the configured Ant pattern + return antPathMatcher.match(pattern, exchange.getRequestPath()); + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java new file mode 100644 index 0000000..fd6d9a6 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java @@ -0,0 +1,31 @@ +package me.cxdev.commerce.proxy.condition; + +import java.util.regex.Pattern; + +import io.undertow.server.HttpServerExchange; + +import org.apache.commons.lang3.StringUtils; + +/** + * Condition that matches the request path against a regular expression. + * Highly useful for targeting REST API endpoints with path variables + * (e.g., matching /occ/v2/.+/users/current). + */ +public class PathRegexCondition implements ExchangeCondition { + private Pattern compiledPattern; + + @Override + public boolean matches(HttpServerExchange exchange) { + if (compiledPattern == null) { + return false; + } + // Match the resolved path (e.g., "/occ/v2/electronics/users/current") + return compiledPattern.matcher(exchange.getRequestPath()).matches(); + } + + public void setRegex(String regex) { + if (StringUtils.isNotBlank(regex)) { + this.compiledPattern = Pattern.compile(regex); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java new file mode 100644 index 0000000..62e24b2 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java @@ -0,0 +1,61 @@ +package me.cxdev.commerce.proxy.handler; + +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; + +import org.apache.commons.lang3.StringUtils; + +import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; + +/** + * Injects configurable CORS (Cross-Origin Resource Sharing) headers into the response. + * Acts as an "Auto-CORS" handler by dynamically echoing the incoming 'Origin' header + * back to the client. If no Origin header is present in the request, no CORS headers + * are injected. + */ +public class CorsInjectorHandler implements ProxyHttpServerExchangeHandler { + private String allowedMethods = "GET, POST, PUT, DELETE, OPTIONS, PATCH"; + private String allowedHeaders = "Authorization, Content-Type, Accept, Origin, X-Requested-With"; + private boolean allowCredentials = false; + + @Override + public void apply(HttpServerExchange exchange) { + String requestOrigin = exchange.getRequestHeaders().getFirst(Headers.ORIGIN); + + // Only inject CORS headers if an Origin is actually present in the request + if (StringUtils.isNotBlank(requestOrigin)) { + exchange.getResponseHeaders().put(new HttpString("Access-Control-Allow-Origin"), requestOrigin); + + if (StringUtils.isNotBlank(allowedMethods)) { + exchange.getResponseHeaders().put(new HttpString("Access-Control-Allow-Methods"), allowedMethods); + } + + if (StringUtils.isNotBlank(allowedHeaders)) { + exchange.getResponseHeaders().put(new HttpString("Access-Control-Allow-Headers"), allowedHeaders); + } + + if (allowCredentials) { + exchange.getResponseHeaders().put(new HttpString("Access-Control-Allow-Credentials"), "true"); + } + } + + // If it's a preflight OPTIONS request, answer it immediately + if (exchange.getRequestMethod().toString().equalsIgnoreCase("OPTIONS")) { + exchange.setStatusCode(200); + exchange.endExchange(); + } + } + + public void setAllowedMethods(String allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public void setAllowedHeaders(String allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public void setAllowCredentials(boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java new file mode 100644 index 0000000..88d0dab --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java @@ -0,0 +1,71 @@ +package me.cxdev.commerce.proxy.handler; + +import java.util.concurrent.ThreadLocalRandom; + +import io.undertow.server.HttpServerExchange; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; + +/** + * Artificially delays the request processing to simulate network latency + * or a slow backend environment. Perfect for testing frontend loading states. + *

+ * Supports a randomized delay between a configured minimum and maximum value. + * Note: Uses Thread.sleep() which blocks the current worker thread. + *

+ */ +public class NetworkDelayHandler implements ProxyHttpServerExchangeHandler { + private static final Logger LOG = LoggerFactory.getLogger(NetworkDelayHandler.class); + + private long minDelayInMillis = 1000; + private long maxDelayInMillis = 1000; + + @Override + public void apply(HttpServerExchange exchange) { + long actualDelay = calculateDelay(); + + if (actualDelay > 0) { + try { + LOG.debug("Simulating network delay of {} ms for request: {}", actualDelay, exchange.getRequestPath()); + Thread.sleep(actualDelay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Network delay simulation was interrupted", e); + } + } + } + + /** + * Calculates the delay to be applied. + * Returns a random value between min and max (inclusive) if they differ. + */ + private long calculateDelay() { + if (minDelayInMillis == maxDelayInMillis) { + return minDelayInMillis; + } + if (minDelayInMillis > maxDelayInMillis) { + LOG.warn("minDelayInMillis ({}) is greater than maxDelayInMillis ({}). Using minDelay.", minDelayInMillis, maxDelayInMillis); + return minDelayInMillis; + } + return ThreadLocalRandom.current().nextLong(minDelayInMillis, maxDelayInMillis + 1); + } + + public void setMinDelayInMillis(long minDelayInMillis) { + this.minDelayInMillis = minDelayInMillis; + } + + public void setMaxDelayInMillis(long maxDelayInMillis) { + this.maxDelayInMillis = maxDelayInMillis; + } + + /** + * Convenience setter to assign a fixed delay (sets both min and max to the same value). + */ + public void setDelayInMillis(long delayInMillis) { + this.minDelayInMillis = delayInMillis; + this.maxDelayInMillis = delayInMillis; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java new file mode 100644 index 0000000..3858e25 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java @@ -0,0 +1,39 @@ +package me.cxdev.commerce.proxy.handler; + +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; + +import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; + +/** + * Short-circuits the request and returns a predefined status code and payload. + * Useful for mocking endpoints that do not yet exist in the backend API, + * or for simulating specific error states (e.g., forcing a 500 Internal Server Error). + */ +public class StaticResponseHandler implements ProxyHttpServerExchangeHandler { + private int statusCode = 200; + private String contentType = "application/json"; + private String responseBody = "{}"; + + @Override + public void apply(HttpServerExchange exchange) { + exchange.setStatusCode(statusCode); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, contentType); + + // Sending the response and ending the exchange prevents further routing to the backend + exchange.getResponseSender().send(responseBody); + exchange.endExchange(); + } + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public void setResponseBody(String responseBody) { + this.responseBody = responseBody; + } +} From 1529002986326aa79bca8bf0230bacf77d2a14e2 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Fri, 27 Feb 2026 22:08:44 +0100 Subject: [PATCH 03/25] finalize documentation, extract interface from JwtTokenService and prepare user token templates --- .../custom/cxdevtools/cxdevproxy/README.md | 225 ++++++++++++------ .../cxdevtools/cxdevproxy/extensioninfo.xml | 1 + .../cxdevtools/cxdevproxy/project.properties | 26 +- .../resources/cxdevproxy-spring.xml | 6 +- .../config/cxdevproxy-additions-spring.xml | 11 + .../config/cxdevproxy-conditions-spring.xml | 59 +++++ .../config/cxdevproxy-handler-spring.xml | 58 ++--- .../config/cxdevproxy-jwt-spring.xml | 17 +- .../jwt/customer/b2bapprover@cxdev.me.json | 11 + .../jwt/customer/b2bcustomer@cxdev.me.json | 11 + .../jwt/customer/b2bmanager@cxdev.me.json | 12 + .../cxdevproxy/jwt/customer/besteller.json | 5 - .../cxdevproxy/jwt/customer/besucher.json | 5 - .../cxdevproxy/jwt/customer/customer.json | 5 - .../jwt/customer/customer@cxdev.me.json | 11 + .../cxdevproxy/jwt/customer/training.json | 5 - .../jwt/customer/visitor@cxdev.me.json | 11 + .../cxdevproxy/jwt/employee/admin.json | 11 +- .../cxdevproxy/jwt/employee/cmsmanager.json | 10 +- .../jwt/employee/productmanager.json | 9 +- .../cxdevproxy/static-content/login.html | 2 +- .../jwt/service/CxJwtTokenService.java | 212 +++++++++++++++++ .../commerce/jwt/service/JwtTokenService.java | 43 ++++ .../handler/ConditionalDelegateHandler.java | 12 +- .../proxy/handler/JwtInjectorHandler.java | 5 +- .../proxy/service/JwtTokenService.java | 184 -------------- 26 files changed, 627 insertions(+), 340 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/JwtTokenService.java delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/service/JwtTokenService.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md index c5c4722..f44309b 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md @@ -1,100 +1,191 @@ # CX Dev Proxy -The `cxdevproxy` extension improves and simplifies the local development experience for SAP Commerce by providing an -embedded, highly configurable Undertow reverse proxy. +The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a transparent reverse proxy in front of your local SAP Commerce instance, solving the most common frontend and headless development pain points out-of-the-box. -## FEATURE DESCRIPTION -This extension solves common local routing and authentication challenges when developing headless storefronts (like -Spartacus/Angular) against a local SAP Commerce backend. It provides a seamless "all-in-one" endpoint for developers. -In addition, it contributes several major features to the local development lifecycle. +## 🚀 Key Features -### Embedded Reverse Proxy & Header Forwarding +* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's native `jwkSource` so tokens are fully trusted by SAP Commerce without any backend configuration. +* **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). +* **Latency Simulation:** Artificially delay API responses to test frontend loading states (spinners, skeletons) locally. +* **Endpoint Mocking:** Short-circuit requests to return static JSON responses for APIs that are not yet implemented in the backend. +* **Spring Extensibility:** Fully configurable and extensible via Spring XML. -A lightweight Undertow server that listens on a unified port (e.g., `8080`) and dynamically routes traffic to either the -local frontend dev server (e.g., Angular on `4200`) or the SAP Commerce backend (Tomcat on `9002`). -The `ForwardedHeadersHandler` automatically injects `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` -headers. This prevents infinite HTTPS redirect loops from Spring Security and ensures that Tomcat generates absolute -URLs correctly. +--- -### Developer Portal & JWT Mocking +## 🛠 Configuration & Properties -Provides a local Developer Portal (accessible via the root or `index.html`) to easily switch between different mocked -user sessions (Employees or Customers). -When a user is selected, the `JwtInjectorHandler` intercepts backend requests, dynamically generates a signed JWT using -the local domain's private key (via Nimbus JOSE+JWT), and injects it as a `Bearer` token. The static JWT claims can be -easily managed via JSON templates. +You can configure the core behavior via your `local.properties` (or `project.properties`). -### Startup Interception +```properties +# ----------------------------------------------------------------------- +# CX Dev Proxy - Configuration +# ----------------------------------------------------------------------- -The `StartupPageHandler` listens to the Hybris tenant lifecycle. While the master tenant is starting up or shutting -down, all incoming proxy requests are intercepted, and a localized, auto-refreshing "503 Service Unavailable" -maintenance page is served to prevent hanging requests or backend errors. +# Enables or disables the proxy +cxdevproxy.enabled=true -### Modular Static Content +# Port on which the proxy will listen +cxdevproxy.server.port=8080 -The `StaticContentHandler` allows serving static files (HTML, CSS, JS, images) directly from the classpath without -invoking the backend server. Files placed in `resources/cxdevproxy/static-content/` are automatically served. +# --- JWT Mocking Configuration --- -### Extensible Conditional Handlers +# Specifies the base path where the proxy looks for JWT claim templates (JSON files). +# The final path is resolved as: //.json +# Supports Spring ResourceLoader prefixes such as 'classpath:' or 'file:'. +# Example for absolute local path: file:/opt/hybris/config/jwt-templates +cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt -The proxy pipeline is highly customizable. The `ConditionalDelegateHandler` allows executing specific handlers only if a -set of conditions is met (e.g., matching HTTP methods, specific paths, or headers). It supports complex logical -expressions via `AndCondition`, `OrCondition`, and `NotCondition`. +# Defines the validity duration of the generated mock JWT tokens. +# Supports smart time units: 's' (seconds), 'm' (minutes), 'h' (hours), 'd' (days). +cxdevproxy.proxy.jwt.validity=10h +``` + +--- + +## 🧩 Building Routing Rules + +The proxy routing is built using a combination of **Conditions** (when should a rule apply?) and **Handlers** (what should happen?). + +### 1. Available Conditions (Abstract Beans) +We provide a comprehensive set of abstract Spring beans that you can use as `parent` definitions to build your own routing logic. + +**Logical Conditions:** +* `cxNotCondition`, `cxAndCondition`, `cxOrCondition` + +**Path-Based Conditions:** +* `cxPathStartsWithCondition`: Matches the exact prefix. +* `cxPathRegexCondition`: Matches paths using Regular Expressions. +* `cxPathAntMatcherCondition`: Matches using Spring's Ant-style path patterns (e.g., `/occ/v2/**`). + +**Request-Based Conditions:** +* `cxHttpMethodCondition`, `cxHeaderExistsCondition`, `cxCookieExistsCondition`, `cxQueryParameterExistsCondition` + +**Pre-configured Standard Paths:** +For convenience, the extension already defines conditions for standard SAP Commerce paths: +* `cxIsAuthorizationServer` +* `cxIsAdminConsole` +* `cxIsBackoffice` +* `cxIsOcc` +* `cxIsSmartEdit` -## How to activate and use +**Pre-configured User Conditions:** +* `cxHasAuthorizationHeader` (Checks if `Authorization` header is present). +* `cxHasMockUser` (Checks for both `cxdevproxy_user_id` and `cxdevproxy_user_type` cookies). -To activate this feature, simply set the `cxdevproxy.enabled` property to `true` in your `local.properties`. +### 2. Available Handlers -**Adding Static Content:** -Other extensions can contribute to the proxy's static files (like adding new pages to the Developer Portal) by simply -placing files inside their own `resources/cxdevproxy/static-content/` directory. The proxy classloader will pick them up -automatically. +* `cxJwtInjectorHandler`: Injects a dynamically generated, natively trusted JWT token into the `Authorization` header. +* `cxCorsInjectorHandler`: Acts as an Auto-CORS responder. By default, `allowCredentials` is set to `false`. +* `cxForwardedHeadersHandler`: Injects standard proxy headers (`X-Forwarded-For`, etc.). +* `cxStaticContentHandler`: Serves local files and assets. -**Adding new Mock Users (JWT Templates):** -To add a new mock user, create a JSON file with the static claims in `resources/cxdevproxy/jwt/employee/.json` or -`resources/cxdevproxy/jwt/customer/.json`. +--- -**Adding Custom Handlers and Conditions:** -You can extend the proxy routing by defining new conditions and handlers in your Spring configuration. Simply create -your custom conditions and inject them into a `ConditionalDelegateHandler` via Spring XML: +## 💡 Advanced Usage & Custom Handlers + +While the standard handlers are pre-registered, you can define project-specific handlers in your custom extension. + +### Simulating Network Delay +Frontend developers often need to test loading states. You can configure a `NetworkDelayHandler` to simulate a slow backend. + +Tip: We utilize the SAP Commerce `listMergeDirective` to seamlessly inject our custom handler into the existing proxy pipeline without overriding the default handlers.* ```xml - - + + + + + + + + + + + + + + + + ``` -Then add your handler to the `backendHandlers` list of the `UndertowProxyManager` bean. +### Mocking Unfinished APIs (Static Response) +If an API does not exist yet, you can short-circuit the request and return a mocked JSON response. +```xml + + + + + + + + + + + + + + -## Configuration parameters + + + +``` + +--- + +## 🔑 JWT Mocking Deep Dive + +The `CxJwtTokenService` is the heart of the local authentication bypass. +It intercepts requests (e.g., via `cxHasMockUser` condition) and injects a signed JWT. + +âš ï¸ **IMPORTANT: OAuth Client ID Requirement**: +By default, our provided B2C and B2B user templates use `storefront` as the client_id. This breaks with the SAP Commerce default (which strangely uses `mobile_android` for OCC). To make the mock tokens work, you must ensure an OAuth Client with the ID storefront is created in your local SAP Commerce database (via ImpEx). We recommend to change the default ID to storefront as this communicates better the intent of the client. + +1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the same one used by the `authorizationserver`). This means the backend trusts the generated tokens implicitly. +2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and `user_id=john@example.com`, it looks for a template at: + `classpath:cxdevproxy/jwt/customer/john@example.com.json` +3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on `cxdevproxy.proxy.jwt.validity`. + +To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=file:/path/to/your/custom/templates` in your local properties. + +--- -| Parameter | Type | Description | -|-----------|------|-------------| -| cxdevproxy.enabled | boolean | Feature toggle. Must be set to `true` to start the Undertow proxy server (default: false). | -| cxdevproxy.application-context | String | Specifies the location of the spring context file automatically added to the global platform application context. | -| cxdevproxy.ssl.enabled | boolean | Enables HTTPS for the Undertow server. If false, forces HTTP routing. | -| cxdevproxy.ssl.keystore.path | String | Absolute path to the PKCS12 keystore used for SSL offloading and JWT signing. | -| cxdevproxy.ssl.keystore.password | String | Password for the configured keystore. | -| cxdevproxy.ssl.keystore.alias | String | Alias of the private key within the keystore used for SSL and JWT signing. | -| cxdevproxy.server.bindaddress | String | Network interface the proxy listens on (e.g., `127.0.0.1` or `0.0.0.0`). | -| cxdevproxy.server.protocol | String | The protocol exposed by the proxy (`http` or `https`). | -| cxdevproxy.server.hostname | String | The public hostname of the proxy (e.g., `local.cxdev.me`). Used for the `X-Forwarded-Host` header. | -| cxdevproxy.server.port | int | The port the Undertow proxy listens on (default: `8080`). | -| cxdevproxy.proxy.frontend.protocol | String | Protocol for frontend routing (e.g., `http` or `https`). | -| cxdevproxy.proxy.frontend.hostname | String | Hostname of the local frontend dev server (e.g., `localhost`). | -| cxdevproxy.proxy.frontend.port | int | Port of the local frontend dev server (e.g., `4200`). | -| cxdevproxy.proxy.backend.protocol | String | Protocol for backend routing (e.g., `https`). | -| cxdevproxy.proxy.backend.hostname | String | Hostname of the local SAP Commerce backend (e.g., `localhost`). | -| cxdevproxy.proxy.backend.port | int | Port of the local SAP Commerce backend (e.g., `9002`). | -| cxdevproxy.proxy.backend.contexts | String | Comma-separated list of paths to route to the backend. If empty, uses auto-discovery via webroot properties. | +## 🗠Extending the Proxy in Your Project +**Do not modify the core `cxdevproxy-spring.xml`!** Instead, add your custom rules, conditions, and handlers to your project-specific extensions. -## License +The `cxdevproxy` extension intentionally leaves an import at the end of its context: +```xml + +``` + +To add custom handlers, simply create a file named `cxdevproxy-additions-spring.xml` inside `resources/cxdevproxy/config/` in your own extension. Since the proxy uses Spring `` aliases (`cxLocalRouteHandlers`, `cxFrontendProxyHandlers`, `cxBackendProxyHandlers`), you can easily override or append to these lists in your custom XML. + +--- -_Licensed under the Apache License, Version 2.0, January 2004_ +### Injecting Mock JWT Tokens for Frontend Development +When working with a headless frontend (like Spartacus/Composable Storefront), you often want to bypass the actual login flow. You can use the `cxJwtInjectorHandler` to automatically replace the `Authorization` header with a valid, locally signed JWT token. With this in place, the frontend can simply send a static token like 'secured' and it will automatically be replaced by the proxy with a valid JWT. -_Copyright 2026, SAP CX Tools_ +To ensure this only happens when appropriate, we combine three pre-configured conditions: it must be an OCC call, the request must have an Authorization header, and the developer must have the mock cookies set. +```xml + + + + + + + + + + + + + + +``` \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml index d7f00b5..48e1319 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml @@ -1,6 +1,7 @@ + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties index 693e16b..a77e809 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties @@ -4,7 +4,6 @@ cxdevproxy.application-context=cxdevproxy-spring.xml # ----------------------------------------------------------------------- # CXDEVPROXY CONFIGURATION # ----------------------------------------------------------------------- - # Feature Toggle: Must be explicitly set to 'true' to start the Undertow proxy server. cxdevproxy.enabled=false @@ -22,13 +21,17 @@ cxdevproxy.server.protocol=https cxdevproxy.server.hostname=local.cxdev.me cxdevproxy.server.port=8080 -# Frontend Routing (Target) +# ----------------------------------------------------------------------- +# CX Dev Proxy - Frontend Routing (Target) +# ----------------------------------------------------------------------- # Target configuration for routing frontend requests (e.g., local Angular dev server). cxdevproxy.proxy.frontend.protocol=https cxdevproxy.proxy.frontend.hostname=localhost cxdevproxy.proxy.frontend.port=4200 -# Backend Routing (Target) +# ----------------------------------------------------------------------- +# CX Dev Proxy - Backend Routing (Target) +# ----------------------------------------------------------------------- # Target configuration for routing SAP Commerce backend requests (local Tomcat). cxdevproxy.proxy.backend.protocol=https cxdevproxy.proxy.backend.hostname=localhost @@ -37,4 +40,19 @@ cxdevproxy.proxy.backend.port=9002 # Backend Contexts (Comma-separated) # Explicit list of URL paths to be routed to the backend. # If left empty, auto-discovery will automatically determine backend routes via the .webroot properties. -cxdevproxy.proxy.backend.contexts= \ No newline at end of file +cxdevproxy.proxy.backend.contexts= + +# ----------------------------------------------------------------------- +# CX Dev Proxy - JWT Mocking Configuration +# ----------------------------------------------------------------------- +# Specifies the base path where the proxy looks for JWT claim templates (JSON files). +# The final path is resolved as: //.json +# Supports Spring ResourceLoader prefixes such as 'classpath:' or 'file:'. +# Example for absolute local path: file:/opt/hybris/config/jwt-templates +cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt + +# Defines the validity duration of the generated mock JWT tokens. +# Supports smart time units: 's' (seconds), 'm' (minutes), 'h' (hours), 'd' (days). +# If no unit is provided, it defaults to milliseconds. +# Examples: 3600s, 60m, 10h, 1d +cxdevproxy.proxy.jwt.validity=10h \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml index ca82057..e9e06a3 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml @@ -49,7 +49,9 @@ - + - + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml new file mode 100644 index 0000000..b22c9bf --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml new file mode 100644 index 0000000..4a93575 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml index c652e28..44695fd 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml @@ -1,46 +1,10 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -49,7 +13,13 @@ - - - + + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml index 7bec4c1..3b90cd5 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml @@ -1,15 +1,12 @@ - + - - - - + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json new file mode 100644 index 0000000..6b2d437 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "b2bapprover@cxdev.me", + "name": "B2B Approver", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_B2BCUSTOMERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json new file mode 100644 index 0000000..7f0020c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "b2bcustomer@cxdev.me", + "name": "B2B Customer", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_B2BCUSTOMERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json new file mode 100644 index 0000000..cbedffa --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json @@ -0,0 +1,12 @@ +{ + "sub": "b2bmanager@cxdev.me", + "name": "B2B Manager", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_B2BCUSTOMERGROUP", + "ROLE_B2BCUSTOMERMANAGERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json deleted file mode 100644 index c6d8cfc..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "besteller", - "name": "Moritz Besteller", - "authorities": ["ROLE_B2BCUSTOMERGROUP", "ROLE_B2BCUSTOMERADMINGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json deleted file mode 100644 index b02daaa..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "besucher", - "name": "Max Besucher", - "authorities": ["ROLE_B2BCUSTOMERGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json deleted file mode 100644 index 56de521..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "customer@cxdev.me", - "name": "Default Customer", - "authorities": ["ROLE_B2BCUSTOMERGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json new file mode 100644 index 0000000..c3bd050 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "customer@cxdev.me", + "name": "B2C Customer", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_CUSTOMERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json deleted file mode 100644 index 5976966..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "besucher", - "name": "Franz Training", - "authorities": ["ROLE_B2BCUSTOMERGROUP", "ROLE_TRAININGGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json new file mode 100644 index 0000000..0d2ea61 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "visitor@cxdev.me", + "name": "B2C Visitor", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_ANONYMOUS" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json index 8d8be54..d69a584 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json @@ -1,5 +1,12 @@ { "sub": "admin", - "name": "Shop Admin", - "authorities": ["ROLE_ADMINGROUP", "ROLE_EMPLOYEEGROUP"] + "name": "Admin", + "aud": "backoffice", + "client_id": "backoffice", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_ADMINGROUP" + ] } \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json index 2a2608e..42a75c8 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json @@ -1,5 +1,13 @@ { "sub": "cmsmanager", "name": "CMS Manager", - "authorities": ["ROLE_CMSMANAGERGROUP", "ROLE_EMPLOYEEGROUP"] + "aud": "smartedit", + "client_id": "smartedit", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_ADMINGROUP", + "ROLE_CMSMANAGERGROUP" + ] } \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json index a30fe49..84b0c97 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json @@ -1,5 +1,12 @@ { "sub": "productmanager", "name": "Product Manager", - "authorities": ["ROLE_PRODUCTMANAGERGROUP", "ROLE_EMPLOYEEGROUP"] + "aud": "backoffice", + "client_id": "backoffice", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_PRODUCTMANAGERGROUP" + ] } \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html index f247324..b6b239a 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html @@ -47,7 +47,7 @@

🔗 Application Deep Links

🢠Employees (Internal Apps)

-

admin

Shop Admin (Full Access)

+

admin

Admin with Full Access

cmsmanager

SmartEdit & CMS Access

diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java new file mode 100644 index 0000000..d960d03 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java @@ -0,0 +1,212 @@ +package me.cxdev.commerce.jwt.service; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +/** + * Service responsible for loading JWT templates, generating signed tokens, and caching them. + *

+ * It strictly relies on the platform's JWKSource (from the authorizationserver) to sign tokens + * with the exact same key the backend uses, ensuring native trust without additional configuration. + *

+ */ +public class CxJwtTokenService implements JwtTokenService, InitializingBean, ResourceLoaderAware { + private static final Logger LOG = LoggerFactory.getLogger(CxJwtTokenService.class); + + private ResourceLoader resourceLoader; + private String templatePathPrefix = "classpath:cxdevproxy/jwt"; + private long tokenValidityMs = 3600 * 1000L; + private JWKSource jwkSource; + private String activeKeyId; + private PrivateKey privateKey; + private final Map tokenCache = new ConcurrentHashMap<>(); + + @Override + public String getOrGenerateToken(String userType, String userId) { + if (privateKey == null) { + return null; + } + + String cacheKey = userType + ":" + userId; + CachedToken cached = tokenCache.get(cacheKey); + + if (cached != null && cached.isValid()) { + return cached.getToken(); + } + + String newToken = generateSignedToken(userType, userId); + if (newToken != null) { + // Cache expires 1 minute before the actual token to avoid edge cases + long cacheExpiry = System.currentTimeMillis() + this.tokenValidityMs - 60000; + tokenCache.put(cacheKey, new CachedToken(newToken, cacheExpiry)); + } + return newToken; + } + + @Override + public String generateSignedToken(String userType, String userId) { + String normalizedPrefix = templatePathPrefix.endsWith("/") ? templatePathPrefix : templatePathPrefix + "/"; + String templatePath = normalizedPrefix + userType + "/" + userId + ".json"; + + try { + Resource resource = resourceLoader.getResource(templatePath); + if (!resource.exists()) { + LOG.warn("No JWT template found for user at path: {}", templatePath); + return null; + } + + String jsonContent; + try (InputStream is = resource.getInputStream()) { + jsonContent = IOUtils.toString(is, StandardCharsets.UTF_8); + } + + JWTClaimsSet templateClaims = JWTClaimsSet.parse(jsonContent); + + Date now = new Date(); + Date expiry = new Date(now.getTime() + this.tokenValidityMs); + + JWTClaimsSet finalClaims = new JWTClaimsSet.Builder(templateClaims) + .notBeforeTime(now) + .issueTime(now) + .expirationTime(expiry) + .issuer("cxdevproxy") + .build(); + + JWSSigner signer = new RSASSASigner(this.privateKey); + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(this.activeKeyId) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, finalClaims); + signedJWT.sign(signer); + + LOG.debug("Successfully generated natively-trusted signed JWT for user '{}' of type '{}'", userId, userType); + return signedJWT.serialize(); + + } catch (Exception e) { + LOG.error("Failed to generate JWT for user '{}' of type '{}'", userId, userType, e); + return null; + } + } + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + public void setTemplatePathPrefix(String templatePathPrefix) { + if (StringUtils.isNotBlank(templatePathPrefix)) { + this.templatePathPrefix = templatePathPrefix; + } + } + + /** + * Smart setter that parses strings like "1d", "10h", "60m", "3600s", "3600000ms" into milliseconds. + * Falls back to raw milliseconds if no unit is provided. + */ + public void setTokenValidity(String validity) { + if (StringUtils.isBlank(validity)) { + return; + } + String val = validity.trim().toLowerCase(); + try { + if (val.endsWith("ms")) { + this.tokenValidityMs = Long.parseLong(val.replace("ms", "")); + } else if (val.endsWith("s")) { + this.tokenValidityMs = Long.parseLong(val.replace("s", "")) * 1000L; + } else if (val.endsWith("m")) { + this.tokenValidityMs = Long.parseLong(val.replace("m", "")) * 60 * 1000L; + } else if (val.endsWith("h")) { + this.tokenValidityMs = Long.parseLong(val.replace("h", "")) * 60 * 60 * 1000L; + } else if (val.endsWith("d")) { + this.tokenValidityMs = Long.parseLong(val.replace("d", "")) * 24 * 60 * 60 * 1000L; + } else { + this.tokenValidityMs = Long.parseLong(val); // default to ms + } + LOG.info("Configured JWT token validity to {} ms", this.tokenValidityMs); + } catch (NumberFormatException e) { + LOG.error("Invalid token validity format '{}'. Using default of 1 hour.", validity); + } + } + + public void setJwkSource(JWKSource jwkSource) { + this.jwkSource = jwkSource; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (jwkSource != null) { + loadPrivateKeyFromJwkSource(); + } else { + LOG.warn("No JWKSource injected. JWT signing will be disabled for the proxy."); + } + } + + private void loadPrivateKeyFromJwkSource() { + try { + JWKSelector selector = new JWKSelector(new JWKMatcher.Builder().build()); + List jwks = jwkSource.get(selector, null); + + if (jwks != null && !jwks.isEmpty()) { + for (JWK jwk : jwks) { + if (jwk instanceof RSAKey && jwk.isPrivate()) { + this.privateKey = ((RSAKey) jwk).toPrivateKey(); + this.activeKeyId = jwk.getKeyID(); + LOG.info("Successfully loaded private key from injected JWKSource (kid: {}). Mock tokens will be natively trusted!", this.activeKeyId); + return; + } + } + } + LOG.error("Injected JWKSource did not contain a valid private RSAKey. JWT signing disabled."); + } catch (Exception e) { + LOG.error("Failed to extract private key from JWKSource. JWT signing disabled.", e); + } + } + + private static class CachedToken { + private final String token; + private final long expiresAt; + + public CachedToken(String token, long expiresAt) { + this.token = token; + this.expiresAt = expiresAt; + } + + public String getToken() { + return token; + } + + public boolean isValid() { + return System.currentTimeMillis() < expiresAt; + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/JwtTokenService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/JwtTokenService.java new file mode 100644 index 0000000..9f2483e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/JwtTokenService.java @@ -0,0 +1,43 @@ +package me.cxdev.commerce.jwt.service; + +/** + * Core service responsible for managing and provisioning JSON Web Tokens (JWT) + * for local development and proxy routing. + *

+ * Implementations of this interface should handle the generation of signed tokens + * (typically using the platform's native keys to ensure trust) based on predefined + * user templates. + *

+ */ +public interface JwtTokenService { + /** + * Retrieves a valid, signed JWT for the specified user. + *

+ * This method should ideally utilize a caching mechanism to prevent unnecessary + * token regeneration. It returns a cached token if one exists and is still valid; + * otherwise, it triggers the generation of a new token. + *

+ * + * @param userType The classification of the user (e.g., "customer", "employee", "admin"). + * This is typically used to locate the correct token template. + * @param userId The unique identifier of the user (e.g., "hans.meier@example.com"). + * @return A Base64-encoded, signed JWT string, or {@code null} if the token + * could not be generated (e.g., due to missing keys or templates). + */ + String getOrGenerateToken(String userType, String userId); + + /** + * Forces the generation of a newly signed JWT for the specified user, + * bypassing any internal caches. + *

+ * This method reads the associated template, computes dynamic claims + * (such as issue time and expiration), and signs the payload. + *

+ * + * @param userType The classification of the user (e.g., "customer", "employee"). + * @param userId The unique identifier of the user. + * @return A newly generated, Base64-encoded, signed JWT string, or {@code null} + * if the generation fails. + */ + String generateSignedToken(String userType, String userId); +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java index 240ab0f..dd925e8 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java @@ -51,12 +51,20 @@ public void apply(HttpServerExchange exchange) { } } + public void setCondition(ExchangeCondition condition) { + this.conditions = List.of(condition); + } + public void setConditions(List conditions) { - this.conditions = conditions; + this.conditions = List.copyOf(conditions); + } + + public void setDelegate(ProxyHttpServerExchangeHandler delegate) { + this.delegates = List.of(delegate); } public void setDelegates(List delegates) { - this.delegates = delegates; + this.delegates = List.copyOf(delegates); } public void setRequireAllConditions(boolean requireAllConditions) { diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java index 4a8b7d5..193a837 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java @@ -8,14 +8,15 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import me.cxdev.commerce.jwt.service.CxJwtTokenService; +import me.cxdev.commerce.jwt.service.JwtTokenService; import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; -import me.cxdev.commerce.proxy.service.JwtTokenService; /** * Interceptor that injects a mocked JWT into the HTTP request before routing it to the backend. *

* It checks the incoming request for a specific cookie ({@code cxdev_user}) set by the - * proxy's developer portal. If found, it requests a signed JWT from the {@link JwtTokenService} + * proxy's developer portal. If found, it requests a signed JWT from the {@link CxJwtTokenService} * and appends it as an standard {@code Authorization: Bearer } header. *

*/ diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/service/JwtTokenService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/service/JwtTokenService.java deleted file mode 100644 index 9c07ec4..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/service/JwtTokenService.java +++ /dev/null @@ -1,184 +0,0 @@ -package me.cxdev.commerce.proxy.service; - -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.security.KeyStore; -import java.security.PrivateKey; -import java.util.Date; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.JWSHeader; -import com.nimbusds.jose.JWSSigner; -import com.nimbusds.jose.crypto.RSASSASigner; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; - -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.InitializingBean; - -/** - * Service responsible for loading JWT templates, generating signed tokens, and caching them. - *

- * It uses the local domain's private key (extracted from the PKCS12 keystore) to sign the - * tokens using the RS256 algorithm. Static claims are loaded from JSON templates in the - * classpath, while dynamic claims (like iat, exp) are calculated on the fly. - *

- */ -public class JwtTokenService implements InitializingBean { - private static final Logger LOG = LoggerFactory.getLogger(JwtTokenService.class); - private static final long TOKEN_VALIDITY_MS = 3600 * 1000L; // 1 hour validity - - private String keystorePath; - private String keystorePassword; - private String keystoreAlias; - - private PrivateKey privateKey; - private final Map tokenCache = new ConcurrentHashMap<>(); - - @Override - public void afterPropertiesSet() throws Exception { - loadPrivateKey(); - } - - /** - * Extracts the private key from the configured PKCS12 keystore. - */ - private void loadPrivateKey() throws Exception { - if (StringUtils.isBlank(keystorePath)) { - LOG.warn("Keystore path not configured. JWT signing will not work."); - return; - } - - File keystoreFile = new File(keystorePath.trim()); - if (!keystoreFile.exists()) { - LOG.error("Keystore not found at {}. JWT signing disabled.", keystoreFile.getAbsolutePath()); - return; - } - - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - char[] password = keystorePassword != null ? keystorePassword.toCharArray() : new char[0]; - - try (InputStream is = new FileInputStream(keystoreFile)) { - keyStore.load(is, password); - } - - KeyStore.PrivateKeyEntry privateKeyEntry = (KeyStore.PrivateKeyEntry) keyStore.getEntry( - keystoreAlias, new KeyStore.PasswordProtection(password)); - - if (privateKeyEntry != null) { - this.privateKey = privateKeyEntry.getPrivateKey(); - LOG.info("Successfully loaded private key for alias '{}' for JWT signing.", keystoreAlias); - } else { - LOG.error("Could not find private key for alias '{}'.", keystoreAlias); - } - } - - /** - * Retrieves a signed JWT for the given user ID. - * Uses a cached token if it is still valid; otherwise, generates a new one. - * - * @param userType The type of the user (e.g., "employee"). - * @param userId The ID of the user (e.g., "admin"). - * @return The signed JWT as a Base64 encoded string, or null if generation fails. - */ - public String getOrGenerateToken(String userType, String userId) { - if (privateKey == null) { - return null; - } - - CachedToken cached = tokenCache.get(userId); - if (cached != null && cached.isValid()) { - return cached.getToken(); - } - - String newToken = generateSignedToken(userType, userId); - if (newToken != null) { - // Cache token to expire slightly before the actual JWT expiry to avoid edge cases - tokenCache.put(userId, new CachedToken(newToken, System.currentTimeMillis() + TOKEN_VALIDITY_MS - 60000)); - } - return newToken; - } - - /** - * Reads the JSON template, appends dynamic claims, and signs the JWT. - */ - private String generateSignedToken(String userType, String userId) { - String templatePath = "cxdevproxy/jwt/" + userType + "/" + userId + ".json"; - try (InputStream is = getClass().getClassLoader().getResourceAsStream(templatePath)) { - if (is == null) { - LOG.warn("No JWT template found for user at classpath: {}", templatePath); - return null; - } - - String jsonContent = IOUtils.toString(is, StandardCharsets.UTF_8); - - // Parse static claims from the JSON file - JWTClaimsSet templateClaims = JWTClaimsSet.parse(jsonContent); - - // Calculate dynamic validity - Date now = new Date(); - Date expiry = new Date(now.getTime() + TOKEN_VALIDITY_MS); - - // Merge claims - JWTClaimsSet finalClaims = new JWTClaimsSet.Builder(templateClaims) - .issueTime(now) - .expirationTime(expiry) - .issuer("cxdevproxy") - .build(); - - // Sign the JWT - JWSSigner signer = new RSASSASigner(this.privateKey); - JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(keystoreAlias).build(); - - SignedJWT signedJWT = new SignedJWT(header, finalClaims); - signedJWT.sign(signer); - - LOG.debug("Successfully generated new signed JWT for user '{}'", userId); - return signedJWT.serialize(); - - } catch (Exception e) { - LOG.error("Failed to generate JWT for user '{}'", userId, e); - return null; - } - } - - public void setKeystorePath(String keystorePath) { - this.keystorePath = keystorePath; - } - - public void setKeystorePassword(String keystorePassword) { - this.keystorePassword = keystorePassword; - } - - public void setKeystoreAlias(String keystoreAlias) { - this.keystoreAlias = keystoreAlias; - } - - /** - * Internal wrapper to hold a cached token and its local expiration time. - */ - private static class CachedToken { - private final String token; - private final long expiresAt; - - public CachedToken(String token, long expiresAt) { - this.token = token; - this.expiresAt = expiresAt; - } - - public String getToken() { - return token; - } - - public boolean isValid() { - return System.currentTimeMillis() < expiresAt; - } - } -} From e60beabafd86d2bf1a5a4cf6d49049233ec460e7 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 11:48:54 +0100 Subject: [PATCH 04/25] redesign of proxy internals: - clear separation of concerns for handler, interceptor, condition - condition now live in the context of interceptors only - handler are defined for local resources and mock responses - interceptors are clearly only changing the exchange context - conditions are only constraining the interceptors - add a fluent API for groovy DSL rule definition --- .../custom/cxdevtools/cxdevproxy/README.md | 205 +++++++----------- .../cxdevtools/cxdevproxy/project.properties | 19 ++ .../resources/cxdevproxy-spring.xml | 49 +++-- .../config/cxdevproxy-conditions-spring.xml | 77 +++---- .../config/cxdevproxy-handler-spring.xml | 22 +- .../config/cxdevproxy-interceptor-spring.xml | 21 ++ .../config/cxdevproxy-jwt-spring.xml | 12 - .../cxdevproxy/i18n/messages_de.properties | 15 ++ .../cxdevproxy/i18n/messages_en.properties | 15 ++ .../rulesets/cxdevproxy-backend-rules.groovy | 3 + .../rulesets/cxdevproxy-frontend-rules.groovy | 3 + .../cxdevproxy/static-content/favicon.ico | Bin 15406 -> 0 bytes .../{static-content => ui/proxy}/login.html | 98 ++++++--- .../proxy/condition/AndCondition.java | 25 --- .../proxy/condition/ExchangeCondition.java | 17 -- .../QueryParameterExistsCondition.java | 24 -- .../handler/ConditionalDelegateHandler.java | 73 ------- .../ProxyRouteHandler.java} | 13 +- .../proxy/handler/StartupPageHandler.java | 4 +- .../proxy/handler/StaticContentHandler.java | 138 ++++++++---- .../handler/TemplateRenderingHandler.java | 173 +++++++++++++++ .../i18n/ClasspathMergingMessageSource.java | 146 +++++++++++++ .../CorsInjectorInterceptor.java} | 8 +- .../ForwardedHeadersInterceptor.java} | 8 +- .../JwtInjectorInterceptor.java} | 7 +- .../NetworkDelayInterceptor.java} | 8 +- .../ProxyExchangeInterceptor.java} | 4 +- .../ProxyExchangeInterceptorCondition.java | 46 ++++ .../proxy/interceptor/ProxyInterceptor.java | 89 ++++++++ .../StaticResponseInterceptor.java} | 6 +- .../interceptor/condition/AndCondition.java | 28 +++ .../interceptor/condition/Conditions.java | 155 +++++++++++++ .../condition/CookieExistsCondition.java | 16 +- .../condition/HeaderExistsCondition.java | 16 +- .../condition/HttpMethodCondition.java | 16 +- .../condition/NotCondition.java | 16 +- .../condition/OrCondition.java | 17 +- .../condition/PathAntMatcherCondition.java | 20 +- .../condition/PathRegexCondition.java | 22 +- .../condition/PathStartsWithCondition.java | 14 +- .../QueryParameterExistsCondition.java | 26 +++ .../condition/StaticCondition.java | 25 +++ .../livecycle/GroovyRuleEngineService.java | 129 +++++++++++ .../proxy/livecycle/UndertowProxyManager.java | 204 ++++++++++++++--- .../proxy/util/ResourcePathUtils.java | 64 ++++++ .../cxdev/commerce/proxy/util/TimeUtils.java | 61 ++++++ 46 files changed, 1598 insertions(+), 559 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/favicon.ico rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/{static-content => ui/proxy}/login.html (61%) delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/AndCondition.java delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/ExchangeCondition.java delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/QueryParameterExistsCondition.java delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{livecycle/ProxyLocalRouteHandler.java => handler/ProxyRouteHandler.java} (59%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{handler/CorsInjectorHandler.java => interceptor/CorsInjectorInterceptor.java} (87%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{handler/ForwardedHeadersHandler.java => interceptor/ForwardedHeadersInterceptor.java} (94%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{handler/JwtInjectorHandler.java => interceptor/JwtInjectorInterceptor.java} (90%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{handler/NetworkDelayHandler.java => interceptor/NetworkDelayInterceptor.java} (90%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{livecycle/ProxyHttpServerExchangeHandler.java => interceptor/ProxyExchangeInterceptor.java} (78%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptorCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{handler/StaticResponseHandler.java => interceptor/StaticResponseInterceptor.java} (84%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/Conditions.java rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/CookieExistsCondition.java (62%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/HeaderExistsCondition.java (61%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/HttpMethodCondition.java (61%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/NotCondition.java (53%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/OrCondition.java (50%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/PathAntMatcherCondition.java (60%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/PathRegexCondition.java (68%) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{ => interceptor}/condition/PathStartsWithCondition.java (62%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/QueryParameterExistsCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/StaticCondition.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/ResourcePathUtils.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md index f44309b..33b22c2 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md @@ -1,16 +1,16 @@ # CX Dev Proxy -The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a transparent reverse proxy in front of your local SAP Commerce instance, solving the most common frontend and headless development pain points out-of-the-box. +The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a transparent reverse proxy in front of your local SAP Commerce Tomcat instance, solving the most common frontend and headless development pain points out-of-the-box. ## 🚀 Key Features -* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's native `jwkSource` so tokens are fully trusted by SAP Commerce without any backend configuration. +* **Groovy DSL & Hot-Reloading:** Define routing rules, mock APIs, and inject delays using a highly readable, fluent Groovy DSL. Save the script and the proxy updates instantly with **zero downtime**—no server restarts required! +* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's native `jwkSource`, meaning tokens are fully trusted by SAP Commerce without any backend configuration. * **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). * **Latency Simulation:** Artificially delay API responses to test frontend loading states (spinners, skeletons) locally. * **Endpoint Mocking:** Short-circuit requests to return static JSON responses for APIs that are not yet implemented in the backend. -* **Spring Extensibility:** Fully configurable and extensible via Spring XML. --- @@ -29,163 +29,118 @@ cxdevproxy.enabled=true # Port on which the proxy will listen cxdevproxy.server.port=8080 +# --- Dynamic Routing Rules (Groovy DSL) --- + +# Paths to the Groovy scripts defining the proxy rules. +# Supports 'classpath:' (inside exploded extensions) and 'file:' (absolute path on disk). +cxdevproxy.proxy.frontend.rulefile=classpath:cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy +cxdevproxy.proxy.backend.rulefile=classpath:cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy + # --- JWT Mocking Configuration --- # Specifies the base path where the proxy looks for JWT claim templates (JSON files). -# The final path is resolved as: //.json -# Supports Spring ResourceLoader prefixes such as 'classpath:' or 'file:'. -# Example for absolute local path: file:/opt/hybris/config/jwt-templates cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt -# Defines the validity duration of the generated mock JWT tokens. -# Supports smart time units: 's' (seconds), 'm' (minutes), 'h' (hours), 'd' (days). +# Defines the validity duration of the generated mock JWT tokens (e.g., 3600s, 60m, 10h, 1d). cxdevproxy.proxy.jwt.validity=10h ``` --- -## 🧩 Building Routing Rules +## 🧩 Building Routing Rules (The Groovy DSL) + +Instead of verbose XML, the CX Dev Proxy uses a powerful Groovy Domain Specific Language (DSL). The scripts are hot-reloaded the moment you save them. -The proxy routing is built using a combination of **Conditions** (when should a rule apply?) and **Handlers** (what should happen?). +To provide the best Developer Experience, our rule engine **automatically imports** all handlers and fluent condition factories (`Conditions.*`), and binds existing Spring beans to the script context. -### 1. Available Conditions (Abstract Beans) -We provide a comprehensive set of abstract Spring beans that you can use as `parent` definitions to build your own routing logic. +### 1. Fluent Conditions API +You can build complex routing conditions using our AssertJ-style API. +* `pathStartsWith("/occ")`, `pathMatches("/occ/v2/**")`, `pathRegexMatches(".*")` +* `hasHeader("Authorization")`, `hasCookie("cxdevproxy_user_id")`, `hasParameter("fields")` +* `isMethod("POST")` +* **Logical Operators:** `.and()`, `.or()`, `.not()` -**Logical Conditions:** -* `cxNotCondition`, `cxAndCondition`, `cxOrCondition` +### 2. Pre-configured Spring Variables +The script environment is automatically populated with context-aware variables (derived from your Spring XML) to make routing even easier: +* **Paths:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer` +* **Users:** `hasMockUser`, `hasAuthorizationHeader` +* **Standard Handlers:** `cxForwardedHeadersHandler`, `cxJwtInjectorHandler`, `cxCorsInjectorHandler` -**Path-Based Conditions:** -* `cxPathStartsWithCondition`: Matches the exact prefix. -* `cxPathRegexCondition`: Matches paths using Regular Expressions. -* `cxPathAntMatcherCondition`: Matches using Spring's Ant-style path patterns (e.g., `/occ/v2/**`). +--- -**Request-Based Conditions:** -* `cxHttpMethodCondition`, `cxHeaderExistsCondition`, `cxCookieExistsCondition`, `cxQueryParameterExistsCondition` +## 💡 Usage Examples -**Pre-configured Standard Paths:** -For convenience, the extension already defines conditions for standard SAP Commerce paths: -* `cxIsAuthorizationServer` -* `cxIsAdminConsole` -* `cxIsBackoffice` -* `cxIsOcc` -* `cxIsSmartEdit` +To add custom rules, simply edit the `cxdevproxy-backend-rules.groovy` or `cxdevproxy-frontend-rules.groovy` files. -**Pre-configured User Conditions:** -* `cxHasAuthorizationHeader` (Checks if `Authorization` header is present). -* `cxHasMockUser` (Checks for both `cxdevproxy_user_id` and `cxdevproxy_user_type` cookies). +### 1. The Baseline (Default Script) +Every script must return a list of handlers. The most basic setup simply forwards standard proxy headers: -### 2. Available Handlers +```groovy +def dynamicHandlers = [] +dynamicHandlers << cxForwardedHeadersHandler +return dynamicHandlers +``` -* `cxJwtInjectorHandler`: Injects a dynamically generated, natively trusted JWT token into the `Authorization` header. -* `cxCorsInjectorHandler`: Acts as an Auto-CORS responder. By default, `allowCredentials` is set to `false`. -* `cxForwardedHeadersHandler`: Injects standard proxy headers (`X-Forwarded-For`, etc.). -* `cxStaticContentHandler`: Serves local files and assets. +### 2. Simulating Network Delay +Frontend developers often need to test loading states. You can configure a `NetworkDelayHandler` to simulate a slow backend for specific paths. ---- +```groovy +def dynamicHandlers = [] +dynamicHandlers << cxForwardedHeadersHandler -## 💡 Advanced Usage & Custom Handlers - -While the standard handlers are pre-registered, you can define project-specific handlers in your custom extension. - -### Simulating Network Delay -Frontend developers often need to test loading states. You can configure a `NetworkDelayHandler` to simulate a slow backend. - -Tip: We utilize the SAP Commerce `listMergeDirective` to seamlessly inject our custom handler into the existing proxy pipeline without overriding the default handlers.* - -```xml - - - - - - - - - - - - - - - - - +// Simulate a slow backend calculation +dynamicHandlers << ProxyHandler.builder() +.withCondition( pathMatches("/occ/v2/**/heavy-calculation") ) +.withHandler( new NetworkDelayHandler(800, 2500) ) // min, max delay in ms +.create() + +return dynamicHandlers ``` -### Mocking Unfinished APIs (Static Response) +### 3. Mocking Unfinished APIs (Static Response) If an API does not exist yet, you can short-circuit the request and return a mocked JSON response. -```xml - - - - - - - - - - - - - - - - - - -``` +```groovy +def dynamicHandlers = [] +dynamicHandlers << cxForwardedHeadersHandler ---- +dynamicHandlers << ProxyHandler.builder() +.withCondition( pathMatches("/occ/v2/**/new-feature") ) +.withHandler( new StaticResponseHandler(200, "application/json", '{"status": "mocked", "data": []}') ) +.create() -## 🔑 JWT Mocking Deep Dive +return dynamicHandlers +``` -The `CxJwtTokenService` is the heart of the local authentication bypass. -It intercepts requests (e.g., via `cxHasMockUser` condition) and injects a signed JWT. +### 4. Injecting Mock JWT Tokens for Frontend Development +When working with a headless frontend (like Spartacus/Composable Storefront), you often want to bypass the actual login flow. The frontend can simply send a static token like 'secured', and the proxy will replace it with a valid, locally-signed JWT. -âš ï¸ **IMPORTANT: OAuth Client ID Requirement**: -By default, our provided B2C and B2B user templates use `storefront` as the client_id. This breaks with the SAP Commerce default (which strangely uses `mobile_android` for OCC). To make the mock tokens work, you must ensure an OAuth Client with the ID storefront is created in your local SAP Commerce database (via ImpEx). We recommend to change the default ID to storefront as this communicates better the intent of the client. +```groovy +def dynamicHandlers = [] +dynamicHandlers << cxForwardedHeadersHandler -1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the same one used by the `authorizationserver`). This means the backend trusts the generated tokens implicitly. -2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and `user_id=john@example.com`, it looks for a template at: - `classpath:cxdevproxy/jwt/customer/john@example.com.json` -3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on `cxdevproxy.proxy.jwt.validity`. +// Combine pre-configured conditions seamlessly! +dynamicHandlers << ProxyHandler.builder() +.withCondition( isOcc.and(hasAuthorizationHeader, hasMockUser) ) +.withHandler( cxJwtInjectorHandler ) +.create() -To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=file:/path/to/your/custom/templates` in your local properties. +return dynamicHandlers +``` --- -## 🗠Extending the Proxy in Your Project - -**Do not modify the core `cxdevproxy-spring.xml`!** Instead, add your custom rules, conditions, and handlers to your project-specific extensions. +## 🔑 JWT Mocking Deep Dive -The `cxdevproxy` extension intentionally leaves an import at the end of its context: -```xml - -``` +The `CxJwtTokenService` is the heart of the local authentication bypass. +It intercepts requests (when conditions match) and injects a dynamically signed JWT. -To add custom handlers, simply create a file named `cxdevproxy-additions-spring.xml` inside `resources/cxdevproxy/config/` in your own extension. Since the proxy uses Spring `` aliases (`cxLocalRouteHandlers`, `cxFrontendProxyHandlers`, `cxBackendProxyHandlers`), you can easily override or append to these lists in your custom XML. +> **âš ï¸ IMPORTANT: OAuth Client ID Requirement** +> By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP Commerce default (which strangely uses `mobile_android` for OCC). To make the mock tokens work, you must ensure an OAuth Client with the ID `storefront` is created in your local SAP Commerce database (via ImpEx). ---- +1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the exact same one used by the `authorizationserver`). This means the backend trusts the generated tokens implicitly. No extra backend configuration needed! +2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and `user_id=john@example.com`, it looks for a template at: + `classpath:cxdevproxy/jwt/customer/john@example.com.json` +3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on `cxdevproxy.proxy.jwt.validity`. -### Injecting Mock JWT Tokens for Frontend Development -When working with a headless frontend (like Spartacus/Composable Storefront), you often want to bypass the actual login flow. You can use the `cxJwtInjectorHandler` to automatically replace the `Authorization` header with a valid, locally signed JWT token. With this in place, the frontend can simply send a static token like 'secured' and it will automatically be replaced by the proxy with a valid JWT. - -To ensure this only happens when appropriate, we combine three pre-configured conditions: it must be an OCC call, the request must have an Authorization header, and the developer must have the mock cookies set. - -```xml - - - - - - - - - - - - - - -``` \ No newline at end of file +To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=file:/path/to/your/custom/templates` in your local properties. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties index a77e809..9245097 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties @@ -21,6 +21,19 @@ cxdevproxy.server.protocol=https cxdevproxy.server.hostname=local.cxdev.me cxdevproxy.server.port=8080 +# Rule Configuration reloading +cxdevproxy.proxy.rules.reloadinterval=5s + +# ----------------------------------------------------------------------- +# CX Dev Proxy - Static Files (Target) +# ----------------------------------------------------------------------- +cxdevproxy.proxy.ui.login.showB2C=false +cxdevproxy.proxy.ui.login.showB2B=true +cxdevproxy.proxy.ui.baselocation=cxdevproxy/ui +cxdevproxy.proxy.ui.messages.basename=cxdevproxy/i18n/messages +cxdevproxy.proxy.ui.messages.reloadinterval=5s +cxdevproxy.proxy.ui.messages.codeasfallback=true + # ----------------------------------------------------------------------- # CX Dev Proxy - Frontend Routing (Target) # ----------------------------------------------------------------------- @@ -29,6 +42,9 @@ cxdevproxy.proxy.frontend.protocol=https cxdevproxy.proxy.frontend.hostname=localhost cxdevproxy.proxy.frontend.port=4200 +# Path to the Groovy script defining the frontend proxy rules. +cxdevproxy.proxy.frontend.rules=cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy + # ----------------------------------------------------------------------- # CX Dev Proxy - Backend Routing (Target) # ----------------------------------------------------------------------- @@ -37,6 +53,9 @@ cxdevproxy.proxy.backend.protocol=https cxdevproxy.proxy.backend.hostname=localhost cxdevproxy.proxy.backend.port=9002 +# Path to the Groovy script defining the backend proxy rules. +cxdevproxy.proxy.backend.rules=cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy + # Backend Contexts (Comma-separated) # Explicit list of URL paths to be routed to the backend. # If left empty, auto-discovery will automatically determine backend routes via the .webroot properties. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml index e9e06a3..2abc298 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml @@ -21,37 +21,48 @@ + + - - - + + + + + - - - - - - + + + + + + - - - - - + + + + + + - - - + + + + + + - - + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml index 4a93575..a05c55c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml @@ -2,58 +2,49 @@ - - - - - - - - - - - - - + + - - + + + - - + + + - - + + + - - - - - + + + + + + - - + + - - + + - - + + - - - - - - - - - - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml index 44695fd..842ceaf 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml @@ -4,22 +4,12 @@ - - - - - - - - - - - - + + + + - - - - + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml new file mode 100644 index 0000000..554e8a6 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml deleted file mode 100644 index 3b90cd5..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties new file mode 100644 index 0000000..68c2f67 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties @@ -0,0 +1,15 @@ +page.title=CX Dev Proxy - Mock Login +heading.main=Lokales Auth-Mocking +text.description=Wähle einen Mock-Benutzer aus, um automatisch gültige JWT-Tokens in deine lokalen Storefront-Anfragen zu injizieren. +tab.b2c=B2C Kunden +tab.b2b=B2B Kunden +label.select.user=Benutzerprofil wählen: +user.anonymous=B2C Besucher (visitor@cxdev.me) +user.customer=B2C Kunde (customer@cxdev.me) +user.b2b.customer=B2B Kunde (b2bcustomer@cxdev.me) +user.b2b.approver=B2B Freigeber (b2bapprover@cxdev.me) +user.b2b.manager=B2B Manager (b2bmanager@cxdev.me) +btn.login=Mock-User setzen +btn.logout=Cookies löschen +msg.success=Mock-Cookies erfolgreich gesetzt für: +msg.cleared=Mock-Cookies entfernt. Du surfst nun als normaler Gast. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties new file mode 100644 index 0000000..43aa413 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties @@ -0,0 +1,15 @@ +page.title=CX Dev Proxy - Mock Login +heading.main=Local Auth Mocking +text.description=Select a mock user to automatically inject valid JWT tokens into your local storefront requests. +tab.b2c=B2C Customers +tab.b2b=B2B Customers +label.select.user=Select User Profile: +user.anonymous=B2C Visitor (visitor@cxdev.me) +user.customer=B2C Customer (customer@cxdev.me) +user.b2b.customer=B2B Customer (b2bcustomer@cxdev.me) +user.b2b.approver=B2B Approver (b2bapprover@cxdev.me) +user.b2b.manager=B2B Unit Manager (b2bmanager@cxdev.me) +btn.login=Set Mock User +btn.logout=Clear Cookies +msg.success=Mock cookies successfully set for: +msg.cleared=Mock cookies removed. You are now browsing as a standard guest. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy new file mode 100644 index 0000000..6ee0373 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy @@ -0,0 +1,3 @@ +def interceptor = [] +interceptor << cxForwardedHeadersInterceptor +return interceptor \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy new file mode 100644 index 0000000..6ee0373 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy @@ -0,0 +1,3 @@ +def interceptor = [] +interceptor << cxForwardedHeadersInterceptor +return interceptor \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/favicon.ico b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/favicon.ico deleted file mode 100644 index 9bcfd4b3c2e1a335d09c1a0fb1944efb4ab3cdc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeHuS92p-nq4WRAFz7R`-C(xwtKp#O;uNQd9qkV@4W#DIuIm4c<;Rj;Xx3N-jn5h zS5^D&u{&;e6fv{f2PyQhKjP?QcK7UPw4;zhk1IypNC3=C+`Q)d&UeninKS?9%)dSJ zH-B@6$8VjH{kt<~&Yn4Q=3C$T>izfr{h2dWe)i^@U;qC*XU=@~AI_Zl9?#(=yyC0R zdH5IqR#uV_S}vDGu~+yHLQL8**Dj@oOXoLIfRla zczkXITxw|5OR$-ga9Gp`g}sO;0`Ry^aM`qQIgE%TlZd1Xu-R>}IxQ&gA0ZSA!?-+! zL_mqeS{0c}5i9u>o|nM%cn`FydDwJQFluIys;nWidk0px2X>nV;iwmphyxM7p5MCw zqjnZ5`55fR85m6}L~|=}IdyQEX5nO8->ag&zl#X3>ukCrHud5dWV0hYUkSTi2e;RZ zc+`SQHOeswAna3Pd0_+w#VC}s{g|KX!>ptq^4Uo&vFTMah;!VMF$?BqB~Z#IF*`Yc z(Y`j!%uexsR=E9cIE-?Hy(ZYbKDbkB(Cfb%FN)2hn1uQ z!H^xr)hM>N*H9?uId4g1D`n*C6~xmaDAh`=mBWa~JqV=|*f>3ci*st>JVirB*sKa9 zBSvTx(^!%YU~x_Yi)I{F{R|w|W!`HI9-AB{n;yx03dM~&0-S%9Y6%VxpFbKxz^jLP zegZR+Q7Gm{;ZEk@=XgW|cCmRaGSoIxD6GY>zL~|wRt8qiamcSjV#N&~*O$wrfI>P1 zt7aaB%?)JoQEVM-Az#WM957>1Higa`@1v{n0<0!EHV(EC_L$&y=wZ|?z{>H}oAg*# zDbd$@6$5QoFw)%wqtl9L%n47x1Ib7)Zk~G$HuDna)QjXwfa_@qGZTI2>uN=RXEU0w zUcg9y2m1NG_0rqOpFBpY5JxZ;f^2#et?&Pc>v0jA9LKHOr||pjm>uiHK>H1}UO9`l zi$8|hVt~_O75Byd$6q41mV|t60=-S|qv!fr*o-n9KRCwe!&AgK|Jua~OpkP9wDlrp z`WoRe%Mo-hbA4)%h?)@#sE|&(kj=8sDek?nfpZ>4BB+DUI)?=3FUz$QiP*$B`xk@4 z|HX$wocR{pRW>0${k6YjB7tNw!FBi-O^82?2Y+hcy#G2beG_N?m!ojaH|L7?6z=gC z_x`i@sZz~aOFUP& zMrgvh!Z`;A2Vz@aUl-38?)&H8CA>@zvm7_RPV(GjI*n{Djq>IevW&}#1h0$u5e@|q<=*<{m?^FyT_~}w z!sl@_K1QHfmc!<9B9O1apUES*T7$#qg)g4K68E9cJPEI53S(n~7#!$keCvU1W(d>L zF)Yvb!|P%Ebm`zUNDyG$)Gm!cubG2cJqnF-24>@;7#GZ@C8Rfw;7%0aNo1f}Rzhns zz^s=;ub#!m?T5&&ZzIJfkWay;9fzKI!|BjMvp5d7TZiKDV}vXFh?aL?usUFIIpB`PVGqZ! zd;dPt^$q0s{Z`!~%)C~om_)+2h?R&MTaTWgd~k$fJ&)aocX9Ci0~A&x*gW3C`r!_| zynow`ON@mYXw(ZFGbt?EX^zD#^vn&xkOez0K11p5GgR(9WxqpkvhSI-EHbM(9PysT z^(snR>zv;p3b`O$b`{5Ofw4dZoqiE&*=KQ9@T*EG;nL{1=IkYrh!S&1U;l{O#Xlc5JA;!&>;1Vp$ zLy%8(LAEHv%;EyZr=}pCox$YHEGDOUl=7ZQ6LyZPSl=n4RCHnI?jceuPNX9`B!kPa z8s-sl%)+$P58Xm9)N>tBN}HjOG($de1G1rOXuo~|Q+=(Fbl*bT<+GTZ8W#IKF(tu- zR7!j?i)zu0Qr?Tz)gU(3quk30tgpvWU*qRF2eK;`p5sK+r$8=YgkpXO?YFKn&-X$j z9~JSy!VGc5)DXspx-h$>#N^z9_$&i$4X~(%^(I4q(`90semLxE7+hXh{1MK-0{N5$ zU9Ao1X}f_$*u=fXH9p-7-Le!@Lv5HC?q>dyp`-N%CWl&)ua;o6+29Mfh>hl8)y~21 z;2stB;?gvHxplS;I3g)bjt;_X<6QXs%t8H_9PU9XW?*hQz{bfT3K=)oq6=>3KAk~} z;oepZjgDb(upi?C%yFH!FwotK!I5ETY)+`PYE(AzaEAlLQ64x9axph&(gDPS7OvT8 zL~DoeWU6py%IIym0jtYJY+%JuTO$_dClT_hn4e7iZWmGs8&mka`bgH zV{EVsA)l4`eh^cGZII1PVWpVo`V!)!o$*0S3?+x#u7%&WjB+u-TyKD3X^PKYgVyeY z+GxhqKpVF2-$i+E2a~P$srAiKRVu)qMJkkj}ahq1eHh!l9ll*SAR)zBMZK!yRa2%ISR@lM`%t$AQeUwne(<{pCiB5{`$)%8^z+&y5P zu|cDfqqY`+pK(1JGKpBhMU0*)7dVf6zgj>d89?duKDIx2#hj6YN-;y+SmL}{h(|q~ z|1^p_+o+U$%wGoFesYS_yZczL<@o;)iq#zRn;$N>0de9%shFA9HDh@iESOW}#IIxNe=Ic3N&euVhU+Qnh07KRbchH&)ZC)j-S3`Vtt_#;Lf zxD4666mFLpJ6x~FpZ^>wVgPTzfy(|KR7-OhAMAnIun2q5Pn?~AJrst;%(d)PGUoMg zpLC$L;Sxr=ZepbE5@WR;lAg<0o?{&)AB2-^t1bCwZ)i-Pe&#r&yf%+x}~IGatF8SlfWRhi?e5!C8YY;3Mz?{F2HTPv)G zgB&|0^Rom7#%7aN%DPX&_%MrE)+1hr5;4C8*5w(P7RI5P=!9W@nEO_Z)q_*yxEC$j zDIC4}2;Zdf3`vtV(0009s8M!0 zcrqcjCDzpr%L4MnAmj5c9Bv2KVE}_2H_^!NZn^Xp<|IR6T@wqEZ*XbhWX)+(jzK#& zNSq>tiTQ7!uLqKeaY!a7Ff}!SN!HM#BSYwDX1#U&5^i0;jBD@x7+2r^q1YPF{s^XE z457*%_qK<1ehAsMJjcERBR#j!di5-t&i+tr^OM6!^ZC>1D00O#HcyV2H;+-MRk`K| zxqqc-VNG}Q!kcI#r*QM)J7~H5E-t_MU0nXrx5)#vb8LFi+trStu4c6Ie>dL!J_4mW zDvv)!FvB%oPa&I6vv$3N?i&|bAHIRs3$LU3{WpjyMv!FADXy;}&)Pk=nnjLd?{%Ar z#aV|FV+j~%q^Fg2>3KB0{|*|?{Rj>3zK&~e{Q%>{KK<;&V1JL8i@Vszwu^5fxpR!| zkA99sslfWoiH&=AQP^1L`q{(+>-(1Tuc7188(1Q)iO0;i_vxoNdioScPafgfuYZrV z%^K@v#y!Ivx>&D{_qAZ4vkCpJ*U`p#yLI;akoMic1ncSkPX4dC2}3PS7$nXay!k#> z?>xrczx_Qj^)gmD|GBjS+8bHBv;OV8{A09p3_7p84V8@hgYow6htF~P!9(1C^%!6L z@eimlr}@a$IEVqqh5a(xkM4%|SUbOq4%XkJ&F{fVZl=I^v$j#i?%h-D-TS)TKSAyO zV?6o0KVo&Kj+KO!+`%MrRBNl;U*y-A6HC=BR>|S4)yS!>mr&g* zu||)v&SNgyVgBA*M{T>py}8T$xrXj5R<#b(1lv4f?q@#;HCywL@_0hFJeiz;B&I z*fE8GRf4c{1`*c`W92*|PAOt;zV^%`?v)|xo)ymt*`>UOHG+N^PR#)9njzSh`(cuI zK{I^|x|wFEBu!W%{{Cycz`vHy1)hWy?U_lo{~b?~h{wnWuqC)2#76wV{Q5t&B;N~7 z;8eu+_5Z)-JA}^!F67TSmv6Y2zj_q;nLoep*Z2I(@BSCx|8rmelK1%LeI}D(UG){W zEF8aiEpR2m>q4Ob;hb{0Eb=PC=K{YXychl_aM1$iBAk;Kx#%)E>zsIPn!JecnZVHq z=Ly#cN8x&*3HK82AsmIzg?oI{um97d@Lk{ZN#J}0u1NTv)6-Lt9}->*=Lo!!&;(9t ze}7-(l7!a+k0kI!!aeHs`k($Ud@kHeI7fH}p})d)LOVV_#?jG{ct7F1Z}=tQyua4( z1m`g>t{=`nxh0V|;!)t5a=fm#B(IB{8m`B3=( zSI0kn&aoE8FO$u2yz^KU##DHAj<0b2N@|7cj_W~Oe{qqc6V|Sms~>%u6!)&*n=Nfhce;`j{t4;9w7>*RP|ke|sD4>O*z zKC$a(iNncRk>B!@i?gyeS1%G{kgHlL*Qh6y5l9up7GfWD8Wo%#50nZSbq8`mx@D-z zd54Odh?h6u%dEm*sKFgik&oBI#QIi8ZeKprPaMj4Ym<_joyMY8M-F5W^C}JI zhoE4ctuh)h$M-hf1luI*j~|gh@3@9{VDkZaXn9xp87{m28mX%`lVs49-bn#eTqnR zUu^N}4ou_={VQ3_kaIVfG*D4fQE8W%>+hp_aLAgrLOqGk8xF#%o}>OS0u!;Pc4-0$ z_B|8RA`#M{bbJpsFY8s-+qu01gvm|py!e!3<3eHgfc3hCW3me$?;~)i0)Hl#&*3U@ zq?8(!-EM?IuOK#+aLvrauAPL%!FoVXy@d6JMI%A|&I4rk?;*K$$}v6V*dD>;q_)HH zS|IlDao@!1yX5$iqGnKMd@1bg@p}&u;odSY&%r~D%f$Lqr<_ER{ZEC+HH6hTeEAal zPoLnJ#~t#F$+!g%zW9ReOB_CZj6&Lm_1#sx`qj@^N7O~FKt>+s#>IC<4l5LLQR^`f z3rrEy&+s`Wxi+{S$SZ0W`eEbyy=Tu+fAR|D2QN^4^b+-l&*6?RSCD61n3o_=oKrqJ zMyj-m)29!yMVy?eQm?C55T!0vUK8R@l-!Mn+6dQ;pX-U5kIgjCdWt+U>mw6uVdL@y z`BdV2&XbP(qCq)|s8>OpIgP2&4t_R5&VkxiGzOK`Nxfwdp$N4qhY10PjCgQ{-0ZB# z{W?uj&O147#{%wr^dSyjeuDMKAEN$LIKC2NN^$3adv=z1T2HOgz%5GyuFIGp{+u0YCU8ZM*Z>P0kOW$ns3uj%>~9&Wd=qhw7bJWHH?j#C@(QXlb|CCoLG?5hM0;%2927#4+qcY9dxcfq>U zgT9tVG_uCLb?IF+UpkBH=YGsuh8(ek{Hso=S%-Me1@yFDLrcT^n4O?2QMg>J43+okrRz$uKzYkU;+j<4b?hnbg(ir-v}RumB7BoLTuIIm>>Lx2>0)95)l=kbyioxdHY^;LU3l zKe0~|YXuw1Y^9uq{JC1JW$Go57=H!rM~K65zlyv8>sQu@w{G5me7cWy{Q~1PYkclA zGjo!GT9bZh67tD@a#3pNU4gGSUNbpqlO5yz19Czk+=D=BM^D*v)LfXpO`3BlqcTWv>Vue~%lzsxQUCwo`p=xSRFUPDw6M6#t z!Y5t$oT#e4)R5*`Z_#L4V>f&_0)pGJ_Gwrt$|#c&$dLJg`7<&fl&E?{K*l5C1Q%z zE$Zg|9ODJpUF2~5#2(DW6XYGn$*0)Ni%3OvSgYrl7YY1>P>SZH{0KGjap6@Cdl2#spAURBuidh;6L5uP^I&;7@HV@(@M>Ox}KeQEJST1 zL9RaHp~pqYiNv*@XcE?NhJ0;|I#LiClL^xk0~qgZ!`+|!8YiFs0;A+&B!k`L!iS(! z&x=~4%TCRM^O=g6sCgQYCa<$f?ZfX@V{vf~2AhlPdIUq-RUXeX=j+D;M`t zh`fX&UVzHt=9p=q)ai*+$*ohn`SQR2H+=Sw{|Gtx`?-mJC}#wYP{kaoAlB57Yh6Z= z`z^*;k|VEP%GtSh+Mv=b!$JHu&OIQv05#UOY=oA!p8*r)Pref^kL8Jv`BS ziyUhw*S8iMWglwfpUZo!$+pX^(;`S0g*rjQ+@+@8Xu`qiE*^aRk~vL=6zd*;aUBM4 z6dJ3G*w@Y+#l5SZ!IyvddwlZuza>98j2Y%AVgB9RI}yKu{az*~tl*l|Fy^TlS0=G6 z8-aXb7O`RpyRSZnoO{S>H}lzC7#|zvdQw5ox?p0s4+`RBfwT6~n=;kkO1*Q0cuhtf z#>4$OkM8!H9LGMCncoBtRWzvQ|H3%DbBJ~Fz@=P-Jdcu`Fnt93)N&aIEn#xX{s8md zH2Y_u4~VsREk};tLY;F8YU(P%0QvD+oVbO0Q`mxHf&6va$-Z)3&+r^-N`jBe>9%5- z`c*1JEyZs_dgp|GEw=r;Vl5<>5!LmX;kj`LCX zu(5Xc&VxG~yEL{pshPW! zhW$4pUfW@e_HnHYF;6X$tB7M~lh}e9d$r`ndezUo?VwIbT+JFrNxfSo8xs9*LfxsD zARC#z@9o=1@X}K_$lOsR$NcE0pX1@@pCF!z(+j~myts;3Ws5qZk=!zC zV8%CLEQ(p?LDrHB#10tkqBjPh` z6+~R?^V)ImC%<5R+~9W;n-Sw#tvc%b)c3g0qpNjt&Ii;DkYQp~MP#*;%X*mlyLM z#&yOT)~JF%Owb0s)F9U%+(Z4|Beo}4JK=t$?z~nCVE>R@4}D=86>)CJPrr_m`fU-9 ze)T&X{q(me9i1XTy-v__)3q8l?>~fv_{MOFaM(B*DGkh{06m~vy9Pw#Hj(cTlx&k{Wk^#1t* zlk&6 zfqGPH5tM2PJp0>U;^gIX>h)oAo=MbM@0e86aIgjl(8Fn1n&H@wiCEJ|e54a_Rp(W5 zjX{L-?#?F@D1H1?0k%Tfp4KQ8fg3ER5`RO;jh=(Whucaf*xvR(;r9eTN+e9SX$ zta03(mU-ePz39ye(m&_soXaKj53?Q_?`g*3L@)Ex0v5Qo*T|hD=$%V!o`^MyZgGTo zN5xvi!J5g05b5#*<-kr`%%__G5zYCFD_6ysv^W(oB6<@F(Z#xd}R0 z6Ob3R(>G_54Z|qyhKco=p0DFk&ToB-T-7moQsScJF~+?W)bBq+`ScDsHYYI&$G1ZL zOQ?5Olhli*SYweZrH@QTzsAJiO=``IYXiisU$?2@7UC*;5vi%k=wZudob&?IXAWTT=_S1uW#wmeD9VxdpQ!0%eY`u-MffA9nkzxW)-AAN+a$4^nY zeGmCva-!8u1X&mEfA~4}$q$wGPdG2*)ah2JH$En3@C?~hkQyER)*SnY-$otU0OJCE za~>7(Zj9R82{qn6^it>SXuXN%E9cQnubB|%ZjxiW`Tjd%eeIx!Af9mG@ZlY5sK2ET z#tauZ+FUlu{pF_)kRZ2YfQgvS&wMY;&(s)wGOWqxq{EmRAE3_Ji*eRLW5ljwgPj;< z8=*!Yq-Q7^6|lSpF2fi(|1kD%AL7N&KEsP&e1WGw`<&jukFfpd5enOT#0@2SO6jxM zDdXX*m*{S7MB7brC+u&-JKv}F|6R84;M$wt#;x;jVyNRLajcqry@UMTJ$(3^-_Tpk z@1vh$o!WGrZHE{$9CV4^YX|q4QmKGUHb;JwZDAf#dhVpu=VzxSn3)`-&P;xq_;F#5 z_4QOMb6yYUQrMS+@GzeReaoyR42*SF`YT!2(XZqWQ1|3B23VsPnIBhc85}=;Am*ZZ z=A=3Lr6k14x88qCe1---Lr24TuBlFPeDs6R51)*=#W4@@dE7>72;}`u0`EvKpRkTB z^j?SQ^&1-O!w`LMqvMP@^e0YCPSRsQ-=Jgy6BAhZ=s2` z%n=GpJ_^;vo-xu7Gcf~)7=#KqUYjZ3fn zC$U}p;eVu;;QKJMP7bVYBC^5SA-l%?r6%^w;~r!72*%WI~uFdP`-{M|+o4ycwEH1u{#(20KEFf{SE7njreT?G$50Tv15xr|dz3}kW2k38WBoB5T-8V1u-fyDi0{s{72_BH| zi`P!mgX!UViN(mhksB^m3(PGU6dBiwM+vmWGFx6+#-<$i6x z!soc~w&<^E?sc@X zPkwqva?Ht<=U=j>I$bBIUKuw$ zPI?QeZS2y+vCf!PJtP-HZ(04A{(+Caq~DtQP1p|;YcgT{$B0AQxt0YFNnd*tI-5C0 zTr(aR!?(?~}nV^SA-IPSuJ=(^Q_&a3Cp$uVuEH%9PsB(~|DdHyN( zzxW->_Z~BTvZkk(;q>DV$;IxYN(@up-lPwuhW?hT9RJtQcK(OF_ASW9=z(Uv6bhN~ znBKsXXU}j2GhR_k{Ny zrN^U%_r7(O{w88j4Qm_eXdi|LddQ7+P)}>42dM+Ywe@H;>I86N!Z zAF%!WC1ae5KAIf%@0{>=Ft_*&*V*VpZa9bD#Bo5s(t#Gnm}PSPcAPvs z#+_&Pap&nB+r zIYy+kPJa-2a{5z+W~S!mpx*2@@%LT)?VX#y1rVCYIxq4N!q_B3I@W7a1e|m9aGR+? zSj8rAnHj#$(%YRQHcrN@^fy}g`y&%MFXA8O)zv(C6ME|MIj^V*M5!T%h%%BX>7>L+qz=jK|T2fAu~o+N1ve-~Yo1{10UNbrS#p diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/ui/proxy/login.html similarity index 61% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/ui/proxy/login.html index b6b239a..a0d2c98 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/login.html +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/ui/proxy/login.html @@ -3,7 +3,7 @@ - CX Dev Proxy - Portal + #{page.title:CX Dev Proxy - Portal}
-

🚀 CX Dev Proxy

+

🚀 #{heading.main:CX Dev Proxy}

- Currently logged in as:
+ #{msg.loggedin:Currently logged in as:}
None (None) +
+
-

🢠Employees (Internal Apps)

+

🢠#{tab.employee:Employees (Internal Apps)}

-

admin

Admin with Full Access

+

admin

#{user.employee.admin:Admin with Full Access}

-

cmsmanager

SmartEdit & CMS Access

+

cmsmanager

#{user.employee.cms:SmartEdit & CMS Access}

-

productmanager

PIM & Backoffice Access

+

productmanager

#{user.employee.pim:PIM & Backoffice Access}

-

ðŸ›ï¸ Customers (B2C Storefront)

-
+

ðŸ›ï¸ #{tab.b2c:Customers (B2C Storefront)}

+
-

visitor

B2C Visitor

+

visitor

#{user.anonymous:B2C Visitor}

-

customer

B2C Customer

+

customer

#{user.customer:B2C Customer}

-

ðŸ›ï¸ Customers (B2B Storefront)

-
+

🢠#{tab.b2b:Customers (B2B Storefront)}

+
-

b2bcustomer

B2B Customer

+

b2bcustomer

#{user.b2b.customer:B2B Customer}

-

b2bapprover

B2B Approver

+

b2bapprover

#{user.b2b.approver:B2B Approver}

-

b2bmanager

B2B Manager

+

b2bmanager

#{user.b2b.manager:B2B Manager}

diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/AndCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/AndCondition.java deleted file mode 100644 index bc2a138..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/AndCondition.java +++ /dev/null @@ -1,25 +0,0 @@ -package me.cxdev.commerce.proxy.condition; - -import java.util.List; - -import io.undertow.server.HttpServerExchange; - -/** - * A logical AND condition that evaluates to true only if all - * of its underlying conditions match. - */ -public class AndCondition implements ExchangeCondition { - private List conditions; - - @Override - public boolean matches(HttpServerExchange exchange) { - if (conditions == null || conditions.isEmpty()) { - return false; - } - return conditions.stream().allMatch(c -> c.matches(exchange)); - } - - public void setConditions(List conditions) { - this.conditions = conditions; - } -} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/ExchangeCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/ExchangeCondition.java deleted file mode 100644 index 6afe8a6..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/ExchangeCondition.java +++ /dev/null @@ -1,17 +0,0 @@ -package me.cxdev.commerce.proxy.condition; - -import io.undertow.server.HttpServerExchange; - -/** - * Represents a condition that evaluates an incoming HTTP request. - * Used to determine if a specific proxy handler should be executed. - */ -public interface ExchangeCondition { - /** - * Evaluates the condition against the current HTTP exchange. - * - * @param exchange The current Undertow HTTP server exchange. - * @return {@code true} if the condition is met, {@code false} otherwise. - */ - boolean matches(HttpServerExchange exchange); -} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/QueryParameterExistsCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/QueryParameterExistsCondition.java deleted file mode 100644 index e7c923a..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/QueryParameterExistsCondition.java +++ /dev/null @@ -1,24 +0,0 @@ -package me.cxdev.commerce.proxy.condition; - -import io.undertow.server.HttpServerExchange; - -import org.apache.commons.lang3.StringUtils; - -/** - * Condition that matches if the request URL contains a specific query parameter. - */ -public class QueryParameterExistsCondition implements ExchangeCondition { - private String paramName; - - @Override - public boolean matches(HttpServerExchange exchange) { - if (StringUtils.isBlank(paramName)) { - return false; - } - return exchange.getQueryParameters().containsKey(paramName); - } - - public void setParamName(String paramName) { - this.paramName = paramName; - } -} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java deleted file mode 100644 index dd925e8..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java +++ /dev/null @@ -1,73 +0,0 @@ -package me.cxdev.commerce.proxy.handler; - -import java.util.List; - -import io.undertow.server.HttpServerExchange; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import me.cxdev.commerce.proxy.condition.ExchangeCondition; -import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; - -/** - * A composite handler that delegates execution to a list of underlying handlers - * only if a configured set of conditions is met. - *

- * By default, ALL conditions must evaluate to {@code true} (AND logic). - * This can be changed to OR logic by setting {@code requireAllConditions} to {@code false}. - *

- */ -public class ConditionalDelegateHandler implements ProxyHttpServerExchangeHandler { - private static final Logger LOG = LoggerFactory.getLogger(ConditionalDelegateHandler.class); - - private List conditions; - private List delegates; - - // If true, acts as AND. If false, acts as OR. - private boolean requireAllConditions = true; - - /** - * Evaluates the configured conditions. If the criteria are met, the request - * is passed to all configured delegate handlers. - * - * @param exchange The current HTTP server exchange. - */ - @Override - public void apply(HttpServerExchange exchange) { - if (conditions == null || conditions.isEmpty() || delegates == null || delegates.isEmpty()) { - return; - } - - boolean match = requireAllConditions - ? conditions.stream().allMatch(c -> c.matches(exchange)) - : conditions.stream().anyMatch(c -> c.matches(exchange)); - - if (match) { - LOG.debug("Conditions met. Executing {} delegate handler(s) for {}", delegates.size(), exchange.getRequestPath()); - for (ProxyHttpServerExchangeHandler delegate : delegates) { - delegate.apply(exchange); - } - } - } - - public void setCondition(ExchangeCondition condition) { - this.conditions = List.of(condition); - } - - public void setConditions(List conditions) { - this.conditions = List.copyOf(conditions); - } - - public void setDelegate(ProxyHttpServerExchangeHandler delegate) { - this.delegates = List.of(delegate); - } - - public void setDelegates(List delegates) { - this.delegates = List.copyOf(delegates); - } - - public void setRequireAllConditions(boolean requireAllConditions) { - this.requireAllConditions = requireAllConditions; - } -} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/ProxyLocalRouteHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ProxyRouteHandler.java similarity index 59% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/ProxyLocalRouteHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ProxyRouteHandler.java index 95b3387..78ee114 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/ProxyLocalRouteHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ProxyRouteHandler.java @@ -1,5 +1,6 @@ -package me.cxdev.commerce.proxy.livecycle; +package me.cxdev.commerce.proxy.handler; +import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; /** @@ -7,7 +8,7 @@ * bypassing the standard routing to the frontend or backend. * Useful for serving local HTML pages or mocking endpoints. */ -public interface ProxyLocalRouteHandler { +public interface ProxyRouteHandler extends HttpHandler { /** * Determines if this handler is responsible for the current request. * @@ -15,12 +16,4 @@ public interface ProxyLocalRouteHandler { * @return true if this handler should process the request, false otherwise */ boolean matches(HttpServerExchange exchange); - - /** - * Processes the request and sends a direct response to the client. - * - * @param exchange the current HTTP server exchange - * @throws Exception if an error occurs during processing - */ - void handleRequest(HttpServerExchange exchange) throws Exception; } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java index 3e17328..6ed44df 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java @@ -18,8 +18,6 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; -import me.cxdev.commerce.proxy.livecycle.ProxyLocalRouteHandler; - /** * Intercepts incoming requests while the SAP Commerce server is still in its startup phase. *

@@ -28,7 +26,7 @@ * serves an auto-refreshing "503 Service Unavailable" maintenance page using native Java ResourceBundles. *

*/ -public class StartupPageHandler implements ProxyLocalRouteHandler, TenantListener, InitializingBean { +public class StartupPageHandler implements ProxyRouteHandler, TenantListener, InitializingBean { private static final Logger LOG = LoggerFactory.getLogger(StartupPageHandler.class); private static final String BUNDLE_BASE_NAME = "localization/cxdevproxy-locales"; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java index caa9c7a..cd8965d 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java @@ -1,74 +1,116 @@ package me.cxdev.commerce.proxy.handler; -import de.hybris.platform.core.Registry; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; import io.undertow.server.HttpServerExchange; -import io.undertow.server.handlers.resource.ClassPathResourceManager; -import io.undertow.server.handlers.resource.Resource; -import io.undertow.server.handlers.resource.ResourceHandler; -import io.undertow.server.handlers.resource.ResourceManager; +import io.undertow.util.Headers; +import io.undertow.util.Methods; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.util.StreamUtils; -import me.cxdev.commerce.proxy.livecycle.ProxyLocalRouteHandler; +import me.cxdev.commerce.proxy.util.ResourcePathUtils; /** - * A local route handler that serves static files directly from the extension's classpath. - *

- * It looks for files within the {@code resources/static-content} directory. If a requested - * file exists locally, this handler intercepts the request and bypasses the standard - * frontend or backend proxy routing. - *

+ * Serves static assets (CSS, JS, images, fonts) from the configured base location. + * Must be registered after the TemplateRenderingHandler to ensure HTML files + * are interpolated before this handler attempts to serve them as raw bytes. */ -public class StaticContentHandler implements ProxyLocalRouteHandler { +public class StaticContentHandler implements ProxyRouteHandler, ResourceLoaderAware { private static final Logger LOG = LoggerFactory.getLogger(StaticContentHandler.class); - private static final String STATIC_FOLDER = "cxdevproxy/static-content"; - private final ResourceManager resourceManager; - private final ResourceHandler resourceHandler; + private final String baseLocation; + private ResourceLoader resourceLoader; - /** - * Initializes the static content handler. - * Sets up Undertow's native resource management using the current classloader. - */ - public StaticContentHandler() { - // Uses the extension's classloader to resolve files from the resources folder - this.resourceManager = new ClassPathResourceManager(Registry.class.getClassLoader(), STATIC_FOLDER); + // A lightweight MIME type map for the most common web assets + private static final Map MIME_TYPES = new HashMap<>(); + static { + MIME_TYPES.put("css", "text/css"); + MIME_TYPES.put("js", "application/javascript"); + MIME_TYPES.put("json", "application/json"); + MIME_TYPES.put("png", "image/png"); + MIME_TYPES.put("jpg", "image/jpeg"); + MIME_TYPES.put("jpeg", "image/jpeg"); + MIME_TYPES.put("svg", "image/svg+xml"); + MIME_TYPES.put("ico", "image/x-icon"); + MIME_TYPES.put("woff", "font/woff"); + MIME_TYPES.put("woff2", "font/woff2"); + MIME_TYPES.put("ttf", "font/ttf"); + } - // Undertow's native handler for serving static files securely and efficiently - this.resourceHandler = new ResourceHandler(this.resourceManager); + public StaticContentHandler(String baseLocation) { + this.baseLocation = ResourcePathUtils.normalizeDirectoryPath(baseLocation, "UI base location"); + ; } - /** - * Evaluates whether the incoming request targets an existing static file. - * - * @param exchange The current HTTP server exchange. - * @return {@code true} if the requested path matches an existing file in the static folder, {@code false} otherwise. - */ @Override public boolean matches(HttpServerExchange exchange) { - try { - String path = exchange.getRequestPath(); - Resource resource = resourceManager.getResource(path); - - // Match only if the resource actually exists and is a file (not a directory) - return resource != null && !resource.isDirectory(); - } catch (Exception e) { - LOG.error("Error checking for static resource: {}", exchange.getRequestPath(), e); + if (!Methods.GET.equals(exchange.getRequestMethod())) { return false; } + + String path = exchange.getRequestPath(); + if ("/".equals(path)) { + return false; + } + + Resource resource = resourceLoader.getResource(baseLocation + path); + // isReadable() ensures we don't accidentally match directories + return resource.exists() && resource.isReadable(); + } + + @Override + public void handleRequest(HttpServerExchange exchange) { + if (exchange.isInIoThread()) { + exchange.dispatch(this); + return; + } + + String path = exchange.getRequestPath(); + Resource resource = resourceLoader.getResource(baseLocation + path); + + if (!resource.exists() || !resource.isReadable()) { + LOG.warn("Static resource matched but could not be read: {}", path); + exchange.setStatusCode(404); + return; + } + + String extension = getExtension(path); + String mimeType = MIME_TYPES.getOrDefault(extension, "application/octet-stream"); + + exchange.setStatusCode(200); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, mimeType); + + // Undertow requires starting blocking mode before writing to the raw OutputStream + exchange.startBlocking(); + + try (InputStream is = resource.getInputStream()) { + StreamUtils.copy(is, exchange.getOutputStream()); + } catch (IOException e) { + LOG.error("Error serving static file: {}", path, e); + if (!exchange.isResponseStarted()) { + exchange.setStatusCode(500); + } + } + } + + private String getExtension(String path) { + int lastDot = path.lastIndexOf('.'); + if (lastDot != -1 && lastDot < path.length() - 1) { + return path.substring(lastDot + 1).toLowerCase(); + } + return ""; } - /** - * Serves the matched static file to the client. - * - * @param exchange The current HTTP server exchange. - * @throws Exception If an error occurs while reading or writing the file. - */ @Override - public void handleRequest(HttpServerExchange exchange) throws Exception { - // Delegate the actual file serving (MIME types, caching headers, etc.) to Undertow - resourceHandler.handleRequest(exchange); + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java new file mode 100644 index 0000000..235592e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java @@ -0,0 +1,173 @@ +package me.cxdev.commerce.proxy.handler; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import de.hybris.platform.servicelayer.config.ConfigurationService; + +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; +import io.undertow.util.Methods; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSource; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.util.StreamUtils; + +import me.cxdev.commerce.proxy.util.ResourcePathUtils; + +/** + * Intercepts requests for local HTML files, resolves Spring properties (${...}) + * and i18n message bundles (#{...}), and serves the rendered HTML to the browser. + */ +public class TemplateRenderingHandler implements ProxyRouteHandler, ResourceLoaderAware { + private static final Logger LOG = LoggerFactory.getLogger(TemplateRenderingHandler.class); + private static final Pattern PROPERTY_PATTERN = Pattern.compile("%\\{([^}]+)\\}"); + private static final Pattern I18N_PATTERN = Pattern.compile("#\\{([^}]+)\\}"); + + private final String baseLocation; + private final ConfigurationService configurationService; + private final MessageSource messageSource; + private ResourceLoader resourceLoader; + + public TemplateRenderingHandler( + String baseLocation, + ConfigurationService configurationService, + MessageSource messageSource) { + this.baseLocation = ResourcePathUtils.normalizeDirectoryPath(baseLocation, "UI base location"); + this.configurationService = configurationService; + this.messageSource = messageSource; + } + + @Override + public boolean matches(HttpServerExchange exchange) { + if (!Methods.GET.equals(exchange.getRequestMethod())) { + return false; + } + + String path = exchange.getRequestPath(); + if (!path.endsWith(".html")) { + return false; + } + + Resource resource = resourceLoader.getResource(baseLocation + path); + return resource.exists() && resource.isReadable(); + } + + @Override + public void handleRequest(HttpServerExchange exchange) { + if (exchange.isInIoThread()) { + exchange.dispatch(this); + return; + } + + String path = exchange.getRequestPath(); + String fullLocation = baseLocation + path; + Resource resource = resourceLoader.getResource(fullLocation); + + if (!resource.exists()) { + LOG.error("Template suddenly not found at: {}", fullLocation); + exchange.setStatusCode(404); + exchange.getResponseSender().send("404 - Template not found"); + return; + } + + try (InputStream is = resource.getInputStream()) { + String rawHtml = StreamUtils.copyToString(is, StandardCharsets.UTF_8); + String propertiesResolvedHtml = resolveProperties(rawHtml); + Locale userLocale = determineLocale(exchange); + String fullyRenderedHtml = resolveI18nMessages(propertiesResolvedHtml, userLocale); + + exchange.setStatusCode(200); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html; charset=UTF-8"); + exchange.getResponseSender().send(fullyRenderedHtml); + + } catch (IOException e) { + LOG.error("Error reading template file: {}", fullLocation, e); + exchange.setStatusCode(500); + exchange.getResponseSender().send("500 - Internal Server Error rendering template"); + } + } + + /** + * Resolves custom property placeholders in the format %{property.key:defaultValue}. + * This custom syntax prevents collisions with JavaScript template literals (${...}). + */ + private String resolveProperties(String template) { + if (template == null || !template.contains("%{")) { + return template; + } + + Matcher matcher = PROPERTY_PATTERN.matcher(template); + StringBuilder sb = new StringBuilder(); + + while (matcher.find()) { + String expression = matcher.group(1); + String key = expression; + String defaultValue = null; + + int colonIndex = expression.indexOf(':'); + if (colonIndex != -1) { + key = expression.substring(0, colonIndex); + defaultValue = expression.substring(colonIndex + 1); + } + + String resolvedValue = configurationService.getConfiguration().getString(key, defaultValue); + if (resolvedValue == null) { + resolvedValue = key; + } + matcher.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(resolvedValue)); + } + matcher.appendTail(sb); + + return sb.toString(); + } + + private String resolveI18nMessages(String html, Locale locale) { + Matcher matcher = I18N_PATTERN.matcher(html); + StringBuilder sb = new StringBuilder(); + + while (matcher.find()) { + String matchContent = matcher.group(1); + String key = matchContent; + String defaultValue = key; + + int defaultSeparatorIndex = matchContent.indexOf(':'); + if (defaultSeparatorIndex != -1) { + key = matchContent.substring(0, defaultSeparatorIndex); + defaultValue = matchContent.substring(defaultSeparatorIndex + 1); + } + + String resolvedMessage = messageSource.getMessage(key, null, defaultValue, locale); + matcher.appendReplacement(sb, Matcher.quoteReplacement(resolvedMessage)); + } + matcher.appendTail(sb); + + return sb.toString(); + } + + private Locale determineLocale(HttpServerExchange exchange) { + String acceptLanguage = exchange.getRequestHeaders().getFirst(Headers.ACCEPT_LANGUAGE); + if (acceptLanguage != null && !acceptLanguage.isEmpty()) { + String primaryTag = acceptLanguage.split(",")[0].trim(); + try { + return Locale.forLanguageTag(primaryTag); + } catch (Exception e) { + LOG.trace("Could not parse language tag: {}", primaryTag); + } + } + return Locale.ENGLISH; + } + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java new file mode 100644 index 0000000..3097f21 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java @@ -0,0 +1,146 @@ +package me.cxdev.commerce.proxy.i18n; + +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.AbstractMessageSource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.PropertiesLoaderUtils; + +import me.cxdev.commerce.proxy.util.TimeUtils; + +/** + * A custom MessageSource that scans the entire classpath across all SAP Commerce extensions, + * merges all matching property files, and automatically hot-reloads them if they are + * modified on the local filesystem (exploded extensions). + */ +public class ClasspathMergingMessageSource extends AbstractMessageSource { + private static final Logger LOG = LoggerFactory.getLogger(ClasspathMergingMessageSource.class); + + private String baseName = "cxdevproxy/i18n/messages"; + private long cacheRefreshIntervalMillis = 5000; + + private final ConcurrentHashMap cachedBundles = new ConcurrentHashMap<>(); + + public void setBaseName(String baseName) { + this.baseName = baseName; + } + + /** + * Smart setter allowing human-readable time intervals like "5s", "10m", "1h", etc. + * Fallback to milliseconds if no unit is provided. + * + * @param interval The interval string from Spring properties. + */ + public void setCacheRefreshIntervalMillis(String interval) { + this.cacheRefreshIntervalMillis = TimeUtils.parseIntervalToMillis(interval, 5000L, "Message cache refresh interval"); + } + + @Override + protected MessageFormat resolveCode(String code, Locale locale) { + String format = resolveCodeWithoutArguments(code, locale); + return format != null ? new MessageFormat(format, locale) : null; + } + + @Override + protected String resolveCodeWithoutArguments(String code, Locale locale) { + CachedBundle bundle = cachedBundles.compute(locale, (loc, currentBundle) -> { + if (currentBundle == null || currentBundle.isStale(cacheRefreshIntervalMillis)) { + if (currentBundle != null) { + LOG.info("Detected change in message files for locale '{}'. Reloading merged bundles...", loc.getLanguage()); + } + return loadMergedProperties(loc); + } + return currentBundle; + }); + + return bundle.getProperties().getProperty(code); + } + + private CachedBundle loadMergedProperties(Locale locale) { + String resourcePattern = "classpath*:" + baseName + "_" + locale.getLanguage() + ".properties"; + + Properties mergedProps = new Properties(); + List watchedFiles = new ArrayList<>(); + + try { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(getClass().getClassLoader()); + Resource[] resources = resolver.getResources(resourcePattern); + + for (Resource resource : resources) { + try { + Properties p = PropertiesLoaderUtils.loadProperties(resource); + mergedProps.putAll(p); + + try { + File file = resource.getFile(); + watchedFiles.add(new WatchedFile(file, file.lastModified())); + LOG.debug("Watching message file for changes: {}", file.getAbsolutePath()); + } catch (IOException e) { + LOG.debug("Resource is not a file on the filesystem (likely in a JAR). Not watching: {}", resource.getURI()); + } + } catch (IOException e) { + LOG.warn("Could not load properties from resource: {}", resource, e); + } + } + } catch (IOException e) { + LOG.error("Failed to resolve message bundle pattern: {}", resourcePattern, e); + } + + return new CachedBundle(mergedProps, watchedFiles); + } + + private static class CachedBundle { + private final Properties properties; + private final List watchedFiles; + private long lastCheckTime; + + CachedBundle(Properties properties, List watchedFiles) { + this.properties = properties; + this.watchedFiles = watchedFiles; + this.lastCheckTime = System.currentTimeMillis(); + } + + Properties getProperties() { + return properties; + } + + boolean isStale(long debounceMillis) { + long now = System.currentTimeMillis(); + if (now - lastCheckTime < debounceMillis) { + return false; + } + this.lastCheckTime = now; + + for (WatchedFile watchedFile : watchedFiles) { + if (watchedFile.hasChanged()) { + return true; + } + } + return false; + } + } + + private static class WatchedFile { + private final File file; + private final long lastModifiedAtLoad; + + WatchedFile(File file, long lastModifiedAtLoad) { + this.file = file; + this.lastModifiedAtLoad = lastModifiedAtLoad; + } + + boolean hasChanged() { + return file.lastModified() > lastModifiedAtLoad; + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java similarity index 87% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java index 62e24b2..7448de2 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/CorsInjectorHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.handler; +package me.cxdev.commerce.proxy.interceptor; import io.undertow.server.HttpServerExchange; import io.undertow.util.Headers; @@ -6,15 +6,13 @@ import org.apache.commons.lang3.StringUtils; -import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; - /** * Injects configurable CORS (Cross-Origin Resource Sharing) headers into the response. - * Acts as an "Auto-CORS" handler by dynamically echoing the incoming 'Origin' header + * Acts as an "Auto-CORS" modifier by dynamically echoing the incoming 'Origin' header * back to the client. If no Origin header is present in the request, no CORS headers * are injected. */ -public class CorsInjectorHandler implements ProxyHttpServerExchangeHandler { +public class CorsInjectorInterceptor implements ProxyExchangeInterceptor { private String allowedMethods = "GET, POST, PUT, DELETE, OPTIONS, PATCH"; private String allowedHeaders = "Authorization, Content-Type, Accept, Origin, X-Requested-With"; private boolean allowCredentials = false; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ForwardedHeadersHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptor.java similarity index 94% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ForwardedHeadersHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptor.java index 155ee5e..0f038eb 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ForwardedHeadersHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptor.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.handler; +package me.cxdev.commerce.proxy.interceptor; import java.net.InetSocketAddress; @@ -9,8 +9,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; - /** * Interceptor that ensures the {@code X-Forwarded-*} headers are correctly populated * on the incoming HTTP exchange before it is routed to the target system. @@ -21,8 +19,8 @@ * to correctly resolve absolute URLs, avoid redirect loops, and determine the security context. *

*/ -public class ForwardedHeadersHandler implements ProxyHttpServerExchangeHandler { - private static final Logger LOG = LoggerFactory.getLogger(ForwardedHeadersHandler.class); +public class ForwardedHeadersInterceptor implements ProxyExchangeInterceptor { + private static final Logger LOG = LoggerFactory.getLogger(ForwardedHeadersInterceptor.class); private String serverProtocol; private String serverHostname; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java similarity index 90% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java index 193a837..3312caa 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/JwtInjectorHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.handler; +package me.cxdev.commerce.proxy.interceptor; import io.undertow.server.HttpServerExchange; import io.undertow.server.handlers.Cookie; @@ -10,7 +10,6 @@ import me.cxdev.commerce.jwt.service.CxJwtTokenService; import me.cxdev.commerce.jwt.service.JwtTokenService; -import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; /** * Interceptor that injects a mocked JWT into the HTTP request before routing it to the backend. @@ -20,8 +19,8 @@ * and appends it as an standard {@code Authorization: Bearer } header. *

*/ -public class JwtInjectorHandler implements ProxyHttpServerExchangeHandler { - private static final Logger LOG = LoggerFactory.getLogger(JwtInjectorHandler.class); +public class JwtInjectorInterceptor implements ProxyExchangeInterceptor { + private static final Logger LOG = LoggerFactory.getLogger(JwtInjectorInterceptor.class); private static final String USER_ID_COOKIE_NAME = "cxdevproxy_user_id"; private static final String USER_TYPE_COOKIE_NAME = "cxdevproxy_user_type"; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java similarity index 90% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java index 88d0dab..a47d015 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/NetworkDelayHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.handler; +package me.cxdev.commerce.proxy.interceptor; import java.util.concurrent.ThreadLocalRandom; @@ -7,8 +7,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; - /** * Artificially delays the request processing to simulate network latency * or a slow backend environment. Perfect for testing frontend loading states. @@ -17,8 +15,8 @@ * Note: Uses Thread.sleep() which blocks the current worker thread. *

*/ -public class NetworkDelayHandler implements ProxyHttpServerExchangeHandler { - private static final Logger LOG = LoggerFactory.getLogger(NetworkDelayHandler.class); +public class NetworkDelayInterceptor implements ProxyExchangeInterceptor { + private static final Logger LOG = LoggerFactory.getLogger(NetworkDelayInterceptor.class); private long minDelayInMillis = 1000; private long maxDelayInMillis = 1000; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/ProxyHttpServerExchangeHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptor.java similarity index 78% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/ProxyHttpServerExchangeHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptor.java index b530313..2c39ba3 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/ProxyHttpServerExchangeHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptor.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.livecycle; +package me.cxdev.commerce.proxy.interceptor; import io.undertow.server.HttpServerExchange; @@ -6,7 +6,7 @@ * Interface for applying custom rules and headers to an Undertow HttpServerExchange * before it is proxied to the target server. */ -public interface ProxyHttpServerExchangeHandler { +public interface ProxyExchangeInterceptor { /** * Applies rules or modifications to the exchange. * * @param exchange the current HTTP server exchange diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptorCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptorCondition.java new file mode 100644 index 0000000..1681cd0 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyExchangeInterceptorCondition.java @@ -0,0 +1,46 @@ +package me.cxdev.commerce.proxy.interceptor; + +import io.undertow.server.HttpServerExchange; + +import me.cxdev.commerce.proxy.interceptor.condition.Conditions; + +/** + * Represents a condition that evaluates an incoming HTTP request. + * Used to determine if a specific proxy interceptor should be executed. + */ +public interface ProxyExchangeInterceptorCondition { + /** + * Evaluates the condition against the current HTTP exchange. + * + * @param exchange The current Undertow HTTP server exchange. + * @return {@code true} if the condition is met, {@code false} otherwise. + */ + boolean matches(HttpServerExchange exchange); + + default ProxyExchangeInterceptorCondition not() { + return Conditions.not(this); + } + + default ProxyExchangeInterceptorCondition and(ProxyExchangeInterceptorCondition... others) { + return Conditions.and(combineWith(others)); + } + + default ProxyExchangeInterceptorCondition or(ProxyExchangeInterceptorCondition... others) { + return Conditions.or(combineWith(others)); + } + + /** + * Helper method to efficiently merge 'this' condition with an array of other conditions + * without creating intermediate Collection objects. + */ + private ProxyExchangeInterceptorCondition[] combineWith(ProxyExchangeInterceptorCondition... others) { + if (others == null || others.length == 0) { + return new ProxyExchangeInterceptorCondition[] { this }; + } + + ProxyExchangeInterceptorCondition[] combined = new ProxyExchangeInterceptorCondition[others.length + 1]; + combined[0] = this; + System.arraycopy(others, 0, combined, 1, others.length); + return combined; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java new file mode 100644 index 0000000..6a2e3ed --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java @@ -0,0 +1,89 @@ +package me.cxdev.commerce.proxy.interceptor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.undertow.server.HttpServerExchange; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A composite interceptor that delegates execution to a list of underlying interceptors + * only if a configured set of conditions is met. + *

+ * By default, ALL conditions must evaluate to {@code true} (AND logic). + * This can be changed to OR logic by setting {@code requireAllConditions} to {@code false}. + *

+ */ +public class ProxyInterceptor implements ProxyExchangeInterceptor { + private static final Logger LOG = LoggerFactory.getLogger(ProxyInterceptor.class); + + private List conditions; + private List delegates; + + // If true, acts as AND. If false, acts as OR. + private boolean requireAllConditions = true; + + private ProxyInterceptor( + List conditions, + List delegates, + boolean requireAllConditions) { + this.conditions = List.copyOf(conditions); + this.delegates = List.copyOf(delegates); + this.requireAllConditions = requireAllConditions; + } + + /** + * Evaluates the configured conditions. If the criteria are met, the request + * is passed to all configured delegate handlers. + * + * @param exchange The current HTTP server exchange. + */ + @Override + public void apply(HttpServerExchange exchange) { + if (conditions == null || conditions.isEmpty() || delegates == null || delegates.isEmpty()) { + return; + } + + boolean match = requireAllConditions + ? conditions.stream().allMatch(c -> c.matches(exchange)) + : conditions.stream().anyMatch(c -> c.matches(exchange)); + + if (match) { + LOG.debug("Conditions met. Executing {} delegate handler(s) for {}", delegates.size(), exchange.getRequestPath()); + for (ProxyExchangeInterceptor delegate : delegates) { + delegate.apply(exchange); + } + } + } + + public static Builder interceptor() { + return new Builder(); + } + + public static class Builder { + private final List conditions = new ArrayList<>(); + private boolean requireAllConditions = true; + + public Builder constrainedBy(ProxyExchangeInterceptorCondition... conditions) { + if (conditions != null) { + this.conditions.addAll(Arrays.asList(conditions)); + } + return this; + } + + public Builder requireAll(boolean value) { + this.requireAllConditions = value; + return this; + } + + public ProxyInterceptor perform(ProxyExchangeInterceptor... interceptor) { + return new ProxyInterceptor(this.conditions, Arrays.asList(interceptor), this.requireAllConditions); + } + + private Builder() { + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java similarity index 84% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java index 3858e25..0fa2d82 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticResponseHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java @@ -1,16 +1,14 @@ -package me.cxdev.commerce.proxy.handler; +package me.cxdev.commerce.proxy.interceptor; import io.undertow.server.HttpServerExchange; import io.undertow.util.Headers; -import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler; - /** * Short-circuits the request and returns a predefined status code and payload. * Useful for mocking endpoints that do not yet exist in the backend API, * or for simulating specific error states (e.g., forcing a 500 Internal Server Error). */ -public class StaticResponseHandler implements ProxyHttpServerExchangeHandler { +public class StaticResponseInterceptor implements ProxyExchangeInterceptor { private int statusCode = 200; private String contentType = "application/json"; private String responseBody = "{}"; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java new file mode 100644 index 0000000..a409ace --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java @@ -0,0 +1,28 @@ +package me.cxdev.commerce.proxy.interceptor.condition; + +import java.util.Arrays; +import java.util.List; + +import io.undertow.server.HttpServerExchange; + +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + +/** + * A logical AND condition that evaluates to true only if all + * of its underlying conditions match. + */ +class AndCondition implements ProxyExchangeInterceptorCondition { + private final List conditions; + + AndCondition(ProxyExchangeInterceptorCondition[] conditions) { + this.conditions = Arrays.asList(conditions); + } + + @Override + public boolean matches(HttpServerExchange exchange) { + if (conditions == null || conditions.isEmpty()) { + return false; + } + return conditions.stream().allMatch(c -> c.matches(exchange)); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/Conditions.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/Conditions.java new file mode 100644 index 0000000..3f524bf --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/Conditions.java @@ -0,0 +1,155 @@ +package me.cxdev.commerce.proxy.interceptor.condition; + +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + +/** + * Static factory methods for creating {@link ProxyExchangeInterceptorCondition} instances fluently within the Groovy DSL. + *

+ * This class provides a highly readable, AssertJ-style API for composing request matching rules. + * Because these methods are statically imported into the Groovy script context by the rule engine, + * developers can use them directly to build concise routing conditions + * (e.g., {@code pathMatches("/occ/**").and(hasHeader("Authorization"))}). + *

+ */ +public final class Conditions { + private Conditions() { + // Prevent instantiation + } + + /** + * Combines multiple conditions using a logical AND operation. + * Evaluates to true only if ALL provided conditions evaluate to true. + * + * @param conditions The conditions to combine. + * @return A composite AND condition. + */ + public static ProxyExchangeInterceptorCondition and(final ProxyExchangeInterceptorCondition... conditions) { + if (conditions == null || conditions.length == 0) { + return never(); + } else if (conditions.length == 1) { + return conditions[0]; + } else { + return new AndCondition(conditions); + } + } + + /** + * Combines multiple conditions using a logical OR operation. + * Evaluates to true if AT LEAST ONE of the provided conditions evaluates to true. + * + * @param conditions The conditions to combine. + * @return A composite OR condition. + */ + public static ProxyExchangeInterceptorCondition or(final ProxyExchangeInterceptorCondition... conditions) { + if (conditions == null || conditions.length == 0) { + return never(); + } else if (conditions.length == 1) { + return conditions[0]; + } else { + return new OrCondition(conditions); + } + } + + /** + * Negates the given condition using a logical NOT operation. + * Evaluates to true only if the provided condition evaluates to false. + * + * @param condition The condition to negate. + * @return A negated condition, or a condition that never matches if the input is null. + */ + public static ProxyExchangeInterceptorCondition not(final ProxyExchangeInterceptorCondition condition) { + return condition == null ? never() : new NotCondition(condition); + } + + /** + * Returns a condition that inherently always evaluates to true. + * Useful as a fallback, default route, or starting point for logical chaining. + * + * @return A condition that always matches. + */ + public static ProxyExchangeInterceptorCondition always() { + return StaticCondition.ALWAYS; + } + + /** + * Returns a condition that inherently never evaluates to true. + * Useful for disabling routes or as a base for dynamic logical structures. + * + * @return A condition that never matches. + */ + public static ProxyExchangeInterceptorCondition never() { + return StaticCondition.NEVER; + } + + /** + * Matches if the incoming request URI strictly starts with the specified prefix. + * + * @param prefix The exact URL prefix (e.g., "/authorizationserver"). + * @return A path prefix matching condition. + */ + public static ProxyExchangeInterceptorCondition pathStartsWith(String prefix) { + return new PathStartsWithCondition(prefix); + } + + /** + * Matches the incoming request URI against a Spring Ant-style pattern. + * + * @param pattern The Ant-style pattern (e.g., "/occ/v2/**"). + * @return An Ant-pattern matching condition. + */ + public static ProxyExchangeInterceptorCondition pathMatches(String pattern) { + return new PathAntMatcherCondition(pattern); + } + + /** + * Matches the incoming request URI against a regular expression. + * + * @param regex The regular expression to test against the request path. + * @return A regex matching condition. + */ + public static ProxyExchangeInterceptorCondition pathRegexMatches(String regex) { + return new PathRegexCondition(regex); + } + + /** + * Matches if the incoming request contains the specified HTTP header. + * The value of the header is not checked, only its presence. + * + * @param headerName The exact name of the HTTP header (e.g., "Authorization"). + * @return A header existence matching condition. + */ + public static ProxyExchangeInterceptorCondition hasHeader(String headerName) { + return new HeaderExistsCondition(headerName); + } + + /** + * Matches if the incoming request contains the specified HTTP cookie. + * The value of the cookie is not checked, only its presence. + * + * @param cookieName The exact name of the cookie (e.g., "cxdevproxy_user_id"). + * @return A cookie existence matching condition. + */ + public static ProxyExchangeInterceptorCondition hasCookie(String cookieName) { + return new CookieExistsCondition(cookieName); + } + + /** + * Matches if the incoming request URL contains the specified query parameter. + * + * @param parameterName The name of the query parameter (e.g., "fields"). + * @return A query parameter existence matching condition. + */ + public static ProxyExchangeInterceptorCondition hasParameter(String parameterName) { + return new QueryParameterExistsCondition(parameterName); + } + + /** + * Matches if the incoming HTTP request method strictly equals the specified value. + * + * @param httpMethod The HTTP method to match (e.g., "GET", "POST", "OPTIONS"). + * @return An HTTP method matching condition. + */ + public static ProxyExchangeInterceptorCondition isMethod(String httpMethod) { + return new HttpMethodCondition(httpMethod); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/CookieExistsCondition.java similarity index 62% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/CookieExistsCondition.java index a6a16ca..07b22ec 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/CookieExistsCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/CookieExistsCondition.java @@ -1,15 +1,21 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import io.undertow.server.HttpServerExchange; import org.apache.commons.lang3.StringUtils; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * Condition that matches if a specific cookie is present in the request. * Useful for routing based on feature toggles, A/B tests, or specific mock users. */ -public class CookieExistsCondition implements ExchangeCondition { - private String cookieName; +class CookieExistsCondition implements ProxyExchangeInterceptorCondition { + private final String cookieName; + + CookieExistsCondition(String cookieName) { + this.cookieName = cookieName; + } @Override public boolean matches(HttpServerExchange exchange) { @@ -18,8 +24,4 @@ public boolean matches(HttpServerExchange exchange) { } return exchange.getRequestCookie(cookieName) != null; } - - public void setCookieName(String cookieName) { - this.cookieName = cookieName; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/HeaderExistsCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/HeaderExistsCondition.java similarity index 61% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/HeaderExistsCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/HeaderExistsCondition.java index 9616a52..fe4fd75 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/HeaderExistsCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/HeaderExistsCondition.java @@ -1,15 +1,21 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import io.undertow.server.HttpServerExchange; import io.undertow.util.HttpString; import org.apache.commons.lang3.StringUtils; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * Condition that matches if the request contains a specific HTTP header. */ -public class HeaderExistsCondition implements ExchangeCondition { - private String headerName; +class HeaderExistsCondition implements ProxyExchangeInterceptorCondition { + private final String headerName; + + HeaderExistsCondition(String headerName) { + this.headerName = headerName; + } @Override public boolean matches(HttpServerExchange exchange) { @@ -18,8 +24,4 @@ public boolean matches(HttpServerExchange exchange) { } return exchange.getRequestHeaders().contains(new HttpString(headerName)); } - - public void setHeaderName(String headerName) { - this.headerName = headerName; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/HttpMethodCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/HttpMethodCondition.java similarity index 61% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/HttpMethodCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/HttpMethodCondition.java index 8ac8c46..fa3cae8 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/HttpMethodCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/HttpMethodCondition.java @@ -1,15 +1,21 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import io.undertow.server.HttpServerExchange; import org.apache.commons.lang3.StringUtils; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * Condition that matches if the HTTP request method (e.g., GET, POST) * equals the configured method. */ -public class HttpMethodCondition implements ExchangeCondition { - private String method; +class HttpMethodCondition implements ProxyExchangeInterceptorCondition { + private final String method; + + HttpMethodCondition(String method) { + this.method = method; + } @Override public boolean matches(HttpServerExchange exchange) { @@ -18,8 +24,4 @@ public boolean matches(HttpServerExchange exchange) { } return exchange.getRequestMethod().toString().equalsIgnoreCase(method); } - - public void setMethod(String method) { - this.method = method; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/NotCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java similarity index 53% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/NotCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java index 6f8e2a8..8bd0588 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/NotCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java @@ -1,12 +1,18 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import io.undertow.server.HttpServerExchange; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * A logical NOT condition that negates the result of a single underlying condition. */ -public class NotCondition implements ExchangeCondition { - private ExchangeCondition condition; +class NotCondition implements ProxyExchangeInterceptorCondition { + private final ProxyExchangeInterceptorCondition condition; + + NotCondition(ProxyExchangeInterceptorCondition condition) { + this.condition = condition; + } @Override public boolean matches(HttpServerExchange exchange) { @@ -15,8 +21,4 @@ public boolean matches(HttpServerExchange exchange) { } return !condition.matches(exchange); } - - public void setCondition(ExchangeCondition condition) { - this.condition = condition; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/OrCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java similarity index 50% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/OrCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java index d38e70e..2c3f2bc 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/OrCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java @@ -1,15 +1,22 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; +import java.util.Arrays; import java.util.List; import io.undertow.server.HttpServerExchange; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * A logical OR condition that evaluates to true if at least one * of its underlying conditions matches. */ -public class OrCondition implements ExchangeCondition { - private List conditions; +class OrCondition implements ProxyExchangeInterceptorCondition { + private final List conditions; + + OrCondition(ProxyExchangeInterceptorCondition[] conditions) { + this.conditions = Arrays.asList(conditions); + } @Override public boolean matches(HttpServerExchange exchange) { @@ -18,8 +25,4 @@ public boolean matches(HttpServerExchange exchange) { } return conditions.stream().anyMatch(c -> c.matches(exchange)); } - - public void setConditions(List conditions) { - this.conditions = conditions; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathAntMatcherCondition.java similarity index 60% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathAntMatcherCondition.java index 030c603..e54bd81 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathAntMatcherCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathAntMatcherCondition.java @@ -1,28 +1,32 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import io.undertow.server.HttpServerExchange; import org.apache.commons.lang3.StringUtils; import org.springframework.util.AntPathMatcher; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * Condition that matches the request path using Spring's AntPathMatcher. * Highly useful for matching paths with standard wildcards. */ -public class PathAntMatcherCondition implements ExchangeCondition { - private String pattern; - private final AntPathMatcher antPathMatcher = new AntPathMatcher(); +class PathAntMatcherCondition implements ProxyExchangeInterceptorCondition { + private final AntPathMatcher antPathMatcher; + private final String pattern; + + PathAntMatcherCondition(String pattern) { + this.antPathMatcher = new AntPathMatcher(); + this.pattern = pattern; + } @Override public boolean matches(HttpServerExchange exchange) { if (StringUtils.isBlank(pattern)) { return false; } + // Match the resolved path against the configured Ant pattern return antPathMatcher.match(pattern, exchange.getRequestPath()); } - - public void setPattern(String pattern) { - this.pattern = pattern; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathRegexCondition.java similarity index 68% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathRegexCondition.java index fd6d9a6..416670a 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathRegexCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathRegexCondition.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import java.util.regex.Pattern; @@ -6,13 +6,23 @@ import org.apache.commons.lang3.StringUtils; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * Condition that matches the request path against a regular expression. * Highly useful for targeting REST API endpoints with path variables * (e.g., matching /occ/v2/.+/users/current). */ -public class PathRegexCondition implements ExchangeCondition { - private Pattern compiledPattern; +class PathRegexCondition implements ProxyExchangeInterceptorCondition { + private final Pattern compiledPattern; + + PathRegexCondition(String regex) { + if (StringUtils.isNotBlank(regex)) { + this.compiledPattern = Pattern.compile(regex); + } else { + this.compiledPattern = null; + } + } @Override public boolean matches(HttpServerExchange exchange) { @@ -22,10 +32,4 @@ public boolean matches(HttpServerExchange exchange) { // Match the resolved path (e.g., "/occ/v2/electronics/users/current") return compiledPattern.matcher(exchange.getRequestPath()).matches(); } - - public void setRegex(String regex) { - if (StringUtils.isNotBlank(regex)) { - this.compiledPattern = Pattern.compile(regex); - } - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathStartsWithCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java similarity index 62% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathStartsWithCondition.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java index fa27c41..fe4fe1f 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/PathStartsWithCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java @@ -1,15 +1,21 @@ -package me.cxdev.commerce.proxy.condition; +package me.cxdev.commerce.proxy.interceptor.condition; import io.undertow.server.HttpServerExchange; import org.apache.commons.lang3.StringUtils; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + /** * Condition that matches if the request path starts with a specific prefix. */ -public class PathStartsWithCondition implements ExchangeCondition { +class PathStartsWithCondition implements ProxyExchangeInterceptorCondition { private String prefix; + PathStartsWithCondition(String prefix) { + this.prefix = prefix; + } + @Override public boolean matches(HttpServerExchange exchange) { if (StringUtils.isBlank(prefix)) { @@ -17,8 +23,4 @@ public boolean matches(HttpServerExchange exchange) { } return exchange.getRequestPath().startsWith(prefix); } - - public void setPrefix(String prefix) { - this.prefix = prefix; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/QueryParameterExistsCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/QueryParameterExistsCondition.java new file mode 100644 index 0000000..676e65c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/QueryParameterExistsCondition.java @@ -0,0 +1,26 @@ +package me.cxdev.commerce.proxy.interceptor.condition; + +import io.undertow.server.HttpServerExchange; + +import org.apache.commons.lang3.StringUtils; + +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + +/** + * Condition that matches if the request URL contains a specific query parameter. + */ +class QueryParameterExistsCondition implements ProxyExchangeInterceptorCondition { + private String name; + + QueryParameterExistsCondition(String name) { + this.name = name; + } + + @Override + public boolean matches(HttpServerExchange exchange) { + if (StringUtils.isBlank(name)) { + return false; + } + return exchange.getQueryParameters().containsKey(name); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/StaticCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/StaticCondition.java new file mode 100644 index 0000000..7670fc1 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/StaticCondition.java @@ -0,0 +1,25 @@ +package me.cxdev.commerce.proxy.interceptor.condition; + +import io.undertow.server.HttpServerExchange; + +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + +/** + * A static condition representing a fixed boolean value. + * Only use the static constants ALWAYS and NEVER. + */ +class StaticCondition implements ProxyExchangeInterceptorCondition { + static final StaticCondition ALWAYS = new StaticCondition(true); + static final StaticCondition NEVER = new StaticCondition(false); + + private final boolean value; + + private StaticCondition(boolean value) { + this.value = value; + } + + @Override + public boolean matches(HttpServerExchange exchange) { + return value; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java new file mode 100644 index 0000000..ad3e052 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java @@ -0,0 +1,129 @@ +package me.cxdev.commerce.proxy.livecycle; + +import java.io.File; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.codehaus.groovy.control.CompilerConfiguration; +import org.codehaus.groovy.control.customizers.ImportCustomizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import groovy.lang.Binding; +import groovy.lang.GroovyShell; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptor; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; +import me.cxdev.commerce.proxy.interceptor.condition.Conditions; + +/** + * Core service responsible for compiling, evaluating, and hot-reloading Groovy DSL scripts. + *

+ * This engine acts as the bridge between the Spring ApplicationContext and the dynamic + * Undertow proxy routing. It initializes a {@link groovy.lang.GroovyShell} and populates + * its binding with pre-configured Spring beans (such as standard proxy handlers and + * pre-defined conditions). + *

+ *

+ * To provide a seamless Developer Experience (DX), it configures the Groovy compiler with + * automatic package imports for handlers and static star imports for the {@link Conditions} + * factory. This enables a clean, fluent, and boilerplate-free DSL for developers to define routing rules. + *

+ */ +public class GroovyRuleEngineService implements ApplicationContextAware, ResourceLoaderAware { + private static final Logger LOG = LoggerFactory.getLogger(GroovyRuleEngineService.class); + private static final String CONDITION_BEAN_PREFIX = "cxdevproxyCondition"; + + private ApplicationContext applicationContext; + private ResourceLoader resourceLoader; + private GroovyShell shell; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + initGroovyShell(); + } + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + private void initGroovyShell() { + Binding binding = new Binding(); + + Map handlers = applicationContext.getBeansOfType(ProxyExchangeInterceptor.class); + for (Map.Entry entry : handlers.entrySet()) { + binding.setVariable(entry.getKey(), entry.getValue()); + LOG.debug("Bound Spring handler bean '{}' to Groovy Context", entry.getKey()); + } + + Map conditions = applicationContext.getBeansOfType(ProxyExchangeInterceptorCondition.class); + for (Map.Entry entry : conditions.entrySet()) { + String beanName = entry.getKey(); + String bindingName = beanName; + if (beanName.startsWith(CONDITION_BEAN_PREFIX) && beanName.length() > CONDITION_BEAN_PREFIX.length()) { + String stripped = beanName.substring(CONDITION_BEAN_PREFIX.length()); + bindingName = Character.toLowerCase(stripped.charAt(0)) + stripped.substring(1); + } + binding.setVariable(bindingName, entry.getValue()); + LOG.debug("Bound Spring condition bean '{}' as '{}' to Groovy Context", beanName, bindingName); + } + + ImportCustomizer importCustomizer = new ImportCustomizer(); + importCustomizer.addStarImports("me.cxdev.commerce.proxy.interceptor"); + importCustomizer.addStaticStars("me.cxdev.commerce.proxy.condition.Conditions"); + + CompilerConfiguration config = new CompilerConfiguration(); + config.addCompilationCustomizers(importCustomizer); + + this.shell = new GroovyShell(this.getClass().getClassLoader(), binding, config); + } + + /** + * Resolves the configured path to a physical File object. Needed for the File-Watcher to check + * lastModified timestamps. + */ + public File resolveScriptFile(String locationPath) { + try { + Resource resource = resourceLoader.getResource("classpath:" + locationPath); + if (resource.exists()) { + // This works flawlessly in local Hybris because extensions are exploded folders + return resource.getFile(); + } else { + LOG.warn("Configured Groovy script not found at path: {}", locationPath); + } + } catch (Exception e) { + LOG.error("Could not resolve path {} to a physical file.", locationPath, e); + } + return null; + } + + @SuppressWarnings("unchecked") + public List evaluateScript(File scriptFile) { + if (scriptFile == null || !scriptFile.exists()) { + return Collections.emptyList(); + } + + try { + LOG.debug("Evaluating Groovy rules from: {}", scriptFile.getAbsolutePath()); + Object result = shell.evaluate(scriptFile); + + if (result instanceof List) { + return (List) result; + } else { + LOG.error("Groovy script {} must return a List", scriptFile.getName()); + } + } catch (Exception e) { + LOG.error("Failed to compile or execute Groovy script: {}", scriptFile.getName(), e); + } + + return Collections.emptyList(); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java index 8f2d7a8..0c05460 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java @@ -2,6 +2,7 @@ import static java.util.function.Predicate.isEqual; import static java.util.function.Predicate.not; +import static org.apache.commons.collections4.ListUtils.emptyIfNull; import java.io.File; import java.io.FileInputStream; @@ -10,8 +11,14 @@ import java.security.KeyStore; import java.util.Arrays; import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; -import javax.net.ssl.*; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; import de.hybris.bootstrap.config.ExtensionInfo; import de.hybris.bootstrap.config.WebExtensionModule; @@ -29,12 +36,18 @@ import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; import org.springframework.context.SmartLifecycle; import org.xnio.OptionMap; import org.xnio.Xnio; import org.xnio.ssl.XnioSsl; +import me.cxdev.commerce.proxy.handler.ProxyRouteHandler; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptor; import me.cxdev.commerce.proxy.trust.AcceptAllTrustManager; +import me.cxdev.commerce.proxy.util.ResourcePathUtils; +import me.cxdev.commerce.proxy.util.TimeUtils; /** * Manages the lifecycle of an embedded Undertow reverse proxy server for SAP Commerce. @@ -45,7 +58,7 @@ * It also supports intercepting requests via custom local route handlers. *

*/ -public class UndertowProxyManager implements SmartLifecycle { +public class UndertowProxyManager implements SmartLifecycle, InitializingBean, DisposableBean { private static final Logger LOG = LoggerFactory.getLogger(UndertowProxyManager.class); // Spring Injected Properties @@ -67,21 +80,112 @@ public class UndertowProxyManager implements SmartLifecycle { private String frontendProtocol; private String frontendHostname; private int frontendPort; + private String frontendRulesFilePath; private String backendProtocol; private String backendHostname; private int backendPort; + private String backendRulesFilePath; private String backendContexts; - // List of Local Routes - private List localRouteHandlers; + // Rule Engine + private long groovyRuleReloadIntervalMs = 5000; + private GroovyRuleEngineService groovyRuleEngineService; + + // Thread-safe references for hot-reloading handlers + private final AtomicReference> frontendHandlersRef = new AtomicReference<>(); + private final AtomicReference> backendHandlersRef = new AtomicReference<>(); - // Proxy Handlers - private List frontendHandlers; - private List backendHandlers; + // Watcher Status + private ScheduledExecutorService watcherExecutor; + private File frontendScriptFile; + private File backendScriptFile; + private long lastModifiedFrontend = 0; + private long lastModifiedBackend = 0; + + // List of Local Routes + private List routeHandlers; private Undertow server; private boolean running = false; + /** + * Initializes the proxy manager after all Spring properties have been set. + * Prepares the atomic handler lists, resolves the Groovy script files, + * triggers the initial script evaluation, and starts the file watcher for hot-reloading. + */ + @Override + public void afterPropertiesSet() throws Exception { + frontendHandlersRef.set(List.of()); + backendHandlersRef.set(List.of()); + + if (groovyRuleEngineService != null) { + frontendScriptFile = groovyRuleEngineService.resolveScriptFile(frontendRulesFilePath); + backendScriptFile = groovyRuleEngineService.resolveScriptFile(backendRulesFilePath); + + reloadFrontendRulesIfChanged(); + reloadBackendRulesIfChanged(); + + startFileWatcher(); + } + } + + /** + * Starts a daemon thread that periodically checks the Groovy rule scripts for modifications. + * If changes are detected, the scripts are recompiled and smoothly hot-swapped. + */ + private void startFileWatcher() { + watcherExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "CxDevProxy-RuleWatcher"); + thread.setDaemon(true); + return thread; + }); + + watcherExecutor.scheduleWithFixedDelay(() -> { + reloadFrontendRulesIfChanged(); + reloadBackendRulesIfChanged(); + }, groovyRuleReloadIntervalMs, groovyRuleReloadIntervalMs, TimeUnit.MILLISECONDS); + + LOG.info("Started Groovy Rule Watcher for dynamic hot-reloading every {} ms.", groovyRuleReloadIntervalMs); + } + + /** + * Evaluates the frontend Groovy script if the file has been modified since the last check. + * Replaces the active handlers atomically to ensure zero proxy downtime. + */ + private void reloadFrontendRulesIfChanged() { + if (frontendScriptFile != null && frontendScriptFile.exists()) { + long currentModified = frontendScriptFile.lastModified(); + if (currentModified > lastModifiedFrontend) { + LOG.info("Detected change in frontend rules script. Compiling and reloading..."); + List newHandlers = groovyRuleEngineService.evaluateScript(frontendScriptFile); + if (newHandlers != null && !newHandlers.isEmpty()) { + frontendHandlersRef.set(newHandlers); // ATOMIC SWAP! + lastModifiedFrontend = currentModified; + LOG.info("Frontend rules successfully reloaded. Active handlers: {}", newHandlers.size()); + } + } + } + } + + /** + * Evaluates the backend Groovy script if the file has been modified since the last check. + * Replaces the active handlers atomically to ensure zero proxy downtime. + */ + private void reloadBackendRulesIfChanged() { + if (backendScriptFile != null && backendScriptFile.exists()) { + long currentModified = backendScriptFile.lastModified(); + if (currentModified > lastModifiedBackend) { + LOG.info("Detected change in backend rules script. Compiling and reloading..."); + List newHandlers = groovyRuleEngineService.evaluateScript(backendScriptFile); + if (newHandlers != null && !newHandlers.isEmpty()) { + backendHandlersRef.set(newHandlers); // ATOMIC SWAP! + lastModifiedBackend = currentModified; + LOG.info("Backend rules successfully reloaded. Active handlers: {}", newHandlers.size()); + } + } + } + } + /** * Starts the embedded Undertow proxy server. * Initializes proxy clients for frontend and backend, applies custom handler rules, @@ -122,13 +226,13 @@ public void start() { LOG.info("Active backend routing contexts: {}", activeBackendContexts); HttpHandler routingHandler = exchange -> { - // 1. Check if a local route handler wants to intercept the request - if (localRouteHandlers != null) { - for (ProxyLocalRouteHandler localHandler : localRouteHandlers) { - if (localHandler.matches(exchange)) { + // 1. Check if a route handler wants to intercept the request + if (routeHandlers != null) { + for (ProxyRouteHandler handler : routeHandlers) { + if (handler.matches(exchange)) { LOG.debug("Serving request {} {} with local handler {}.", exchange.getRequestMethod(), exchange.getRequestURI(), - localHandler.getClass().getSimpleName()); - localHandler.handleRequest(exchange); + handler.getClass().getSimpleName()); + handler.handleRequest(exchange); return; } } @@ -193,10 +297,10 @@ private List determineBackendContexts() { * Routes the incoming HTTP request to either the backend or the frontend proxy handler * based on the request path and the determined backend contexts. * - * @param exchange The current HTTP server exchange. - * @param backendContexts The list of context paths mapped to the backend. - * @param backendHandler The handler responsible for backend routing. - * @param frontendHandler The handler responsible for frontend routing. + * @param exchange The current HTTP server exchange. + * @param backendContexts The list of context paths mapped to the backend. + * @param backendHandler The handler responsible for backend routing. + * @param frontendHandler The handler responsible for frontend routing. * @throws Exception If an error occurs during routing. */ private void routeRequest(HttpServerExchange exchange, List backendContexts, HttpHandler backendHandler, HttpHandler frontendHandler) throws Exception { @@ -214,14 +318,19 @@ private void routeRequest(HttpServerExchange exchange, List backendConte /** * Wraps the base frontend handler with any configured custom interceptors/handlers. + * Pulls the handlers atomically from the reference to ensure thread-safety during hot-reloads. * * @param next The base proxy handler. * @return A chained HTTP handler applying all configured frontend rules. */ protected HttpHandler applyFrontendRules(HttpHandler next) { return exchange -> { - if (frontendHandlers != null) { - frontendHandlers.forEach(handler -> handler.apply(exchange)); + List currentHandlers = frontendHandlersRef.get(); + for (ProxyExchangeInterceptor handler : emptyIfNull(currentHandlers)) { + handler.apply(exchange); + if (exchange.isResponseStarted() || exchange.isComplete()) { + break; + } } next.handleRequest(exchange); }; @@ -229,14 +338,19 @@ protected HttpHandler applyFrontendRules(HttpHandler next) { /** * Wraps the base backend handler with any configured custom interceptors/handlers. + * Pulls the handlers atomically from the reference to ensure thread-safety during hot-reloads. * * @param next The base proxy handler. * @return A chained HTTP handler applying all configured backend rules. */ protected HttpHandler applyBackendRules(HttpHandler next) { return exchange -> { - if (backendHandlers != null) { - backendHandlers.forEach(handler -> handler.apply(exchange)); + List currentHandlers = backendHandlersRef.get(); + for (ProxyExchangeInterceptor handler : emptyIfNull(currentHandlers)) { + handler.apply(exchange); + if (exchange.isResponseStarted() || exchange.isComplete()) { + break; + } } next.handleRequest(exchange); }; @@ -296,7 +410,7 @@ private XnioSsl createTrustAllXnioSsl(String serverNameIndicator) throws Excepti } /** - * Stops the embedded Undertow proxy server and releases resources. + * Stops the embedded Undertow proxy server and updates the running state. */ @Override public void stop() { @@ -307,6 +421,16 @@ public void stop() { } } + /** + * Shuts down the background file watcher executor when the Spring context is destroyed. + */ + @Override + public void destroy() throws Exception { + if (watcherExecutor != null && !watcherExecutor.isShutdown()) { + watcherExecutor.shutdownNow(); + } + } + @Override public boolean isRunning() { return running; @@ -317,7 +441,7 @@ public int getPhase() { return Integer.MAX_VALUE; } - // --- Setters for Spring Injection --- + // --- Standard Setters --- public void setEnabled(boolean enabled) { this.enabled = enabled; @@ -367,6 +491,14 @@ public void setFrontendPort(int frontendPort) { this.frontendPort = frontendPort; } + /** + * Sets the file path for the frontend Groovy rules. + * Automatically normalizes the path to ensure a valid ResourceLoader prefix. + */ + public void setFrontendRulesFilePath(String frontendRulesFilePath) { + this.frontendRulesFilePath = ResourcePathUtils.normalizeFilePath(frontendRulesFilePath, "frontend rules"); + } + public void setBackendProtocol(String backendProtocol) { this.backendProtocol = backendProtocol; } @@ -379,19 +511,33 @@ public void setBackendPort(int backendPort) { this.backendPort = backendPort; } + /** + * Sets the file path for the backend Groovy rules. + * Automatically normalizes the path to ensure a valid ResourceLoader prefix. + */ + public void setBackendRulesFilePath(String backendRulesFilePath) { + this.backendRulesFilePath = ResourcePathUtils.normalizeFilePath(backendRulesFilePath, "backend rules"); + } + public void setBackendContexts(String backendContexts) { this.backendContexts = backendContexts; } - public void setLocalRouteHandlers(List localRouteHandlers) { - this.localRouteHandlers = localRouteHandlers; + /** + * Smart setter allowing human-readable time intervals like "5s", "10m", "1h", etc. + * Fallback to milliseconds if no unit is provided. + * + * @param interval The interval string from Spring properties. + */ + public void setGroovyRuleReloadInterval(String interval) { + this.groovyRuleReloadIntervalMs = TimeUtils.parseIntervalToMillis(interval, 5000L, "Groovy rule reload interval"); } - public void setFrontendHandlers(List frontendHandlers) { - this.frontendHandlers = frontendHandlers; + public void setGroovyRuleEngineService(GroovyRuleEngineService groovyRuleEngineService) { + this.groovyRuleEngineService = groovyRuleEngineService; } - public void setBackendHandlers(List backendHandlers) { - this.backendHandlers = backendHandlers; + public void setRouteHandlers(List routeHandlers) { + this.routeHandlers = routeHandlers; } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/ResourcePathUtils.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/ResourcePathUtils.java new file mode 100644 index 0000000..80f591a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/ResourcePathUtils.java @@ -0,0 +1,64 @@ +package me.cxdev.commerce.proxy.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for normalizing Spring resource paths across the proxy extension. + *

+ * Ensures consistent handling of resource prefixes (like 'classpath:' and 'file:') + * and mitigates common configuration mistakes (such as using 'classpath*:'). + *

+ */ +public final class ResourcePathUtils { + private static final Logger LOG = LoggerFactory.getLogger(ResourcePathUtils.class); + + private ResourcePathUtils() { + // Prevent instantiation of utility class + } + + /** + * Normalizes a file path (e.g., a Groovy script). + * Converts 'classpath*:' to 'classpath:' and auto-prepends 'classpath:' if no protocol is given. + * + * @param path The raw path from properties. + * @param contextName A descriptive name for log warnings (e.g., "frontend rules"). + * @return The normalized, ResourceLoader-compatible path. + */ + public static String normalizeFilePath(String path, String contextName) { + if (path == null || path.trim().isEmpty()) { + return path; + } + return applyPrefixFixes(path.trim(), contextName); + } + + /** + * Normalizes a directory/base path (e.g., a UI folder). + * Removes trailing slashes for consistent concatenation, then applies prefix fixes. + * + * @param path The raw directory path from properties. + * @param contextName A descriptive name for log warnings (e.g., "UI base location"). + * @return The normalized, trailing-slash-free, ResourceLoader-compatible path. + */ + public static String normalizeDirectoryPath(String path, String contextName) { + if (path == null || path.trim().isEmpty()) { + return path; + } + String normalized = path.trim(); + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return applyPrefixFixes(normalized, contextName); + } + + private static String applyPrefixFixes(String path, String contextName) { + if (path.startsWith("classpath*:")) { + LOG.warn("Invalid prefix 'classpath*:' detected for {} ({}). " + + "Automatically correcting prefix to 'classpath:'.", contextName, path); + return "classpath:" + path.substring("classpath*:".length()); + } else if (!path.startsWith("classpath:") && !path.startsWith("file:")) { + return "classpath:" + path; + } + return path; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java new file mode 100644 index 0000000..92b61be --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java @@ -0,0 +1,61 @@ +package me.cxdev.commerce.proxy.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for parsing human-readable time intervals (e.g., "5s", "10m", "1h") + * into milliseconds. + */ +public final class TimeUtils { + private static final Logger LOG = LoggerFactory.getLogger(TimeUtils.class); + + private TimeUtils() { + // Prevent instantiation + } + + /** + * Parses a time interval string into milliseconds. + * Supports 'ms', 's', 'm', 'h', and 'd'. Falls back to milliseconds if no unit is provided. + * + * @param interval The interval string from properties (e.g., "5s"). + * @param defaultValue The fallback value in milliseconds if parsing fails or input is empty. + * @param contextName A descriptive name for logging (e.g., "Groovy rule reload"). + * @return The parsed interval in milliseconds. + */ + public static long parseIntervalToMillis(String interval, long defaultValue, String contextName) { + if (interval == null || interval.trim().isEmpty()) { + return defaultValue; + } + + String trimmed = interval.trim().toLowerCase(); + long multiplier = 1; + long value; + + try { + if (trimmed.endsWith("ms")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 2)); + } else if (trimmed.endsWith("s")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 1000L; + } else if (trimmed.endsWith("m")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 60L * 1000L; + } else if (trimmed.endsWith("h")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 60L * 60L * 1000L; + } else if (trimmed.endsWith("d")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 24L * 60L * 60L * 1000L; + } else { + value = Long.parseLong(trimmed); // Default to ms if no unit + } + long result = value * multiplier; + LOG.debug("Parsed time interval for '{}' to {} ms", contextName, result); + return result; + } catch (NumberFormatException e) { + LOG.error("Invalid time format for {} ('{}'). Using default: {} ms", contextName, interval, defaultValue); + return defaultValue; + } + } +} From f7331812eb5583e488a850350e849b778df867d9 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 12:13:37 +0100 Subject: [PATCH 05/25] update documentation --- .../custom/cxdevtools/cxdevproxy/README.md | 182 ++++++++++-------- 1 file changed, 106 insertions(+), 76 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md index 33b22c2..289f6f2 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md @@ -1,16 +1,23 @@ # CX Dev Proxy -The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a transparent reverse proxy in front of your local SAP Commerce Tomcat instance, solving the most common frontend and headless development pain points out-of-the-box. - - +The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a +transparent reverse proxy in front of your local SAP Commerce Tomcat instance, solving the most common frontend and +headless development pain points out-of-the-box. ## 🚀 Key Features -* **Groovy DSL & Hot-Reloading:** Define routing rules, mock APIs, and inject delays using a highly readable, fluent Groovy DSL. Save the script and the proxy updates instantly with **zero downtime**—no server restarts required! -* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's native `jwkSource`, meaning tokens are fully trusted by SAP Commerce without any backend configuration. -* **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). -* **Latency Simulation:** Artificially delay API responses to test frontend loading states (spinners, skeletons) locally. -* **Endpoint Mocking:** Short-circuit requests to return static JSON responses for APIs that are not yet implemented in the backend. +* **Dynamic Groovy DSL & Hot-Reloading:** Define routing rules and HTTP modifications using a highly readable, fluent + Groovy DSL. Save the script and the proxy updates instantly with **zero downtime**—no server restarts required! +* **Clean Interceptor Architecture:** A strict separation between Request Routing (Handlers) and Request Modification ( + Interceptors) ensures predictable, thread-safe request manipulation. +* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's + native `jwkSource`, meaning tokens are fully trusted by SAP Commerce without any backend configuration. +* **Developer Auth Portal:** An out-of-the-box, bilingual (English/German) UI portal (`/proxy/login.html`) to easily + switch between mock Employee, B2C, and B2B user contexts. +* **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming + Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). +* **Conflict-Free Configuration:** Securely injects Spring backend properties into frontend templates using a custom + `%{property:default}` syntax, eliminating collisions with modern JavaScript. --- @@ -20,9 +27,8 @@ You can configure the core behavior via your `local.properties` (or `project.pro ```properties # ----------------------------------------------------------------------- -# CX Dev Proxy - Configuration +# CX Dev Proxy - Core Configuration # ----------------------------------------------------------------------- - # Enables or disables the proxy cxdevproxy.enabled=true @@ -30,14 +36,17 @@ cxdevproxy.enabled=true cxdevproxy.server.port=8080 # --- Dynamic Routing Rules (Groovy DSL) --- - # Paths to the Groovy scripts defining the proxy rules. # Supports 'classpath:' (inside exploded extensions) and 'file:' (absolute path on disk). -cxdevproxy.proxy.frontend.rulefile=classpath:cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy -cxdevproxy.proxy.backend.rulefile=classpath:cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy +cxdevproxy.proxy.frontend.rules=classpath:cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy +cxdevproxy.proxy.backend.rules=classpath:cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy -# --- JWT Mocking Configuration --- +# --- UI & Auth Portal Configuration --- +# Toggle visibility of customer tabs in the /proxy/login.html portal +cxdevproxy.proxy.ui.login.showB2C=false +cxdevproxy.proxy.ui.login.showB2B=true +# --- JWT Mocking Configuration --- # Specifies the base path where the proxy looks for JWT claim templates (JSON files). cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt @@ -47,100 +56,121 @@ cxdevproxy.proxy.jwt.validity=10h --- -## 🧩 Building Routing Rules (The Groovy DSL) +## 🖥 Developer Auth Portal & Safe Properties -Instead of verbose XML, the CX Dev Proxy uses a powerful Groovy Domain Specific Language (DSL). The scripts are hot-reloaded the moment you save them. +The extension provides a built-in UI to set mock user cookies. You can access it via `/proxy/login.html`. -To provide the best Developer Experience, our rule engine **automatically imports** all handlers and fluent condition factories (`Conditions.*`), and binds existing Spring beans to the script context. +To prevent syntax collisions between Spring property resolution and modern JavaScript template literals (`${...}`), the +HTML templates utilize a custom, robust placeholder syntax: **`%{property.key:defaultValue}`**. -### 1. Fluent Conditions API -You can build complex routing conditions using our AssertJ-style API. -* `pathStartsWith("/occ")`, `pathMatches("/occ/v2/**")`, `pathRegexMatches(".*")` -* `hasHeader("Authorization")`, `hasCookie("cxdevproxy_user_id")`, `hasParameter("fields")` -* `isMethod("POST")` -* **Logical Operators:** `.and()`, `.or()`, `.not()` +This allows you to safely toggle UI elements based on your backend configuration without breaking frontend scripts: -### 2. Pre-configured Spring Variables -The script environment is automatically populated with context-aware variables (derived from your Spring XML) to make routing even easier: -* **Paths:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer` -* **Users:** `hasMockUser`, `hasAuthorizationHeader` -* **Standard Handlers:** `cxForwardedHeadersHandler`, `cxJwtInjectorHandler`, `cxCorsInjectorHandler` +```javascript +// Safely resolved by the proxy's ConfigurationService before reaching the browser +const showB2C = %{cxdevproxy.proxy.ui.login.showB2C:false}; +const showB2B = %{cxdevproxy.proxy.ui.login.showB2B:false}; +``` --- -## 💡 Usage Examples +## 🧩 Building Routing Rules (The Groovy DSL) -To add custom rules, simply edit the `cxdevproxy-backend-rules.groovy` or `cxdevproxy-frontend-rules.groovy` files. +Instead of verbose XML, the CX Dev Proxy uses a powerful Groovy Domain Specific Language (DSL). The scripts are +hot-reloaded the moment you save them. -### 1. The Baseline (Default Script) -Every script must return a list of handlers. The most basic setup simply forwards standard proxy headers: +To provide the best Developer Experience, our rule engine **automatically imports** all interceptors and fluent +condition factories (`Conditions.*`), and binds existing Spring beans to the script context. -```groovy -def dynamicHandlers = [] -dynamicHandlers << cxForwardedHeadersHandler -return dynamicHandlers -``` +### 1. Fluent API -### 2. Simulating Network Delay -Frontend developers often need to test loading states. You can configure a `NetworkDelayHandler` to simulate a slow backend for specific paths. +You can build complex interceptor conditions using our functional, AssertJ-style API. -```groovy -def dynamicHandlers = [] -dynamicHandlers << cxForwardedHeadersHandler +* `isMethod("GET")`, `pathStartsWith("/occ")`, `pathMatches("/occ/v2/**")` +* `hasHeader("Authorization")`, `hasCookie("cxdevproxy_user_id")`, `hasParameter("username")` +* **Logical Operators:** `and(...)`, `or(...)`, `not(...)` — which can be chained (`isOcc.and(hasMockUser)`) or nested ( + `and(isOcc, hasMockUser)`). -// Simulate a slow backend calculation -dynamicHandlers << ProxyHandler.builder() -.withCondition( pathMatches("/occ/v2/**/heavy-calculation") ) -.withHandler( new NetworkDelayHandler(800, 2500) ) // min, max delay in ms -.create() +### 2. Pre-configured Spring Variables -return dynamicHandlers -``` +The script environment is automatically populated with context-aware variables (derived from your Spring XML) to make +routing even easier: -### 3. Mocking Unfinished APIs (Static Response) -If an API does not exist yet, you can short-circuit the request and return a mocked JSON response. +* **Paths:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer` +* **State:** `hasMockUser`, `hasAuthorizationHeader` +* **Available Interceptors:** `cxForwardedHeadersInterceptor`, `cxJwtInjectorInterceptor`, `cxCorsInjectorInterceptor` -```groovy -def dynamicHandlers = [] -dynamicHandlers << cxForwardedHeadersHandler +--- + +## 💡 Usage Examples -dynamicHandlers << ProxyHandler.builder() -.withCondition( pathMatches("/occ/v2/**/new-feature") ) -.withHandler( new StaticResponseHandler(200, "application/json", '{"status": "mocked", "data": []}') ) -.create() +To add custom rules for your project, first override the default rule paths in your `local.properties` to point to your custom project directory: -return dynamicHandlers +```properties +cxdevproxy.proxy.backend.rules=classpath:path/to/your/project/my-backend-rules.groovy +cxdevproxy.proxy.frontend.rules=classpath:path/to/your/project/my-frontend-rules.groovy ``` -### 4. Injecting Mock JWT Tokens for Frontend Development -When working with a headless frontend (like Spartacus/Composable Storefront), you often want to bypass the actual login flow. The frontend can simply send a static token like 'secured', and the proxy will replace it with a valid, locally-signed JWT. +Then, simply create and edit these Groovy files. Every script must return a list of interceptors. + +### 1. The Baseline (Default Script) + +The most basic setup simply applies standard proxy headers unconditionally: ```groovy -def dynamicHandlers = [] -dynamicHandlers << cxForwardedHeadersHandler +return [ + cxForwardedHeadersInterceptor +] +``` -// Combine pre-configured conditions seamlessly! -dynamicHandlers << ProxyHandler.builder() -.withCondition( isOcc.and(hasAuthorizationHeader, hasMockUser) ) -.withHandler( cxJwtInjectorHandler ) -.create() +### 2. Conditional Execution (The Builder Pattern) -return dynamicHandlers +You should never mutate the state of injected Spring beans directly. Instead, wrap them using the stateless +`interceptor()` builder to apply them conditionally. + +```groovy +// Combine pre-configured conditions seamlessly +def jwtCondition = isOcc.or(isSmartEdit) + .and(hasMockUser) + .and(not(hasAuthorizationHeader)) + +return [ + cxForwardedHeadersInterceptor, // Always execute + + // Execute JWT Injector only if the complex condition is met + interceptor() + .constrainedBy(jwtCondition) + .perform(cxJwtInjectorInterceptor), + + // Execute CORS Injector only for OCC requests + interceptor() + .constrainedBy(isOcc) + .perform(cxCorsInjectorInterceptor) +] ``` --- ## 🔑 JWT Mocking Deep Dive -The `CxJwtTokenService` is the heart of the local authentication bypass. +The `cxJwtInjectorInterceptor` is the heart of the local authentication bypass. It intercepts requests (when conditions match) and injects a dynamically signed JWT. > **âš ï¸ IMPORTANT: OAuth Client ID Requirement** -> By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP Commerce default (which strangely uses `mobile_android` for OCC). To make the mock tokens work, you must ensure an OAuth Client with the ID `storefront` is created in your local SAP Commerce database (via ImpEx). - -1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the exact same one used by the `authorizationserver`). This means the backend trusts the generated tokens implicitly. No extra backend configuration needed! -2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and `user_id=john@example.com`, it looks for a template at: +> By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP +> Commerce default (which uses `mobile_android` for OCC). +> To make the mock tokens work, you must ensure an OAuth Client with the ID `storefront` is created in your local SAP +> Commerce database (via ImpEx). +> We understand that this might be questionable, but we are convinced that using a self-explaining `client_id` is a +> best-practise and should be enforced anyway. If you don't agree, feel free to change the templates to your needs. + +1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the exact same one used by the + `authorizationserver`). This means the backend trusts the generated tokens implicitly. No extra backend configuration + needed! +2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and + `user_id=john@example.com`, it looks for a template at: `classpath:cxdevproxy/jwt/customer/john@example.com.json` -3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on `cxdevproxy.proxy.jwt.validity`. +3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on + `cxdevproxy.proxy.jwt.validity`. -To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=file:/path/to/your/custom/templates` in your local properties. \ No newline at end of file +To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=path/to/your/custom/templates` +in your local properties. From 6311e17057a4829db3479b090e35a7b854973c1b Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 12:53:48 +0100 Subject: [PATCH 06/25] add tests for conditions --- .../interceptor/condition/AndCondition.java | 4 +- .../interceptor/condition/NotCondition.java | 4 +- .../interceptor/condition/OrCondition.java | 6 +- .../condition/PathStartsWithCondition.java | 2 +- .../interceptor/condition/ConditionsTest.java | 247 ++++++++++++++++++ 5 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/condition/ConditionsTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java index a409ace..8aed034 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java @@ -15,14 +15,12 @@ class AndCondition implements ProxyExchangeInterceptorCondition { private final List conditions; AndCondition(ProxyExchangeInterceptorCondition[] conditions) { + assert conditions != null; this.conditions = Arrays.asList(conditions); } @Override public boolean matches(HttpServerExchange exchange) { - if (conditions == null || conditions.isEmpty()) { - return false; - } return conditions.stream().allMatch(c -> c.matches(exchange)); } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java index 8bd0588..d453457 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java @@ -11,14 +11,12 @@ class NotCondition implements ProxyExchangeInterceptorCondition { private final ProxyExchangeInterceptorCondition condition; NotCondition(ProxyExchangeInterceptorCondition condition) { + assert condition != null; this.condition = condition; } @Override public boolean matches(HttpServerExchange exchange) { - if (condition == null) { - return false; // Fail-safe if not properly configured - } return !condition.matches(exchange); } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java index 2c3f2bc..ac75ece 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java @@ -14,15 +14,13 @@ class OrCondition implements ProxyExchangeInterceptorCondition { private final List conditions; - OrCondition(ProxyExchangeInterceptorCondition[] conditions) { + OrCondition(ProxyExchangeInterceptorCondition... conditions) { + assert conditions != null; this.conditions = Arrays.asList(conditions); } @Override public boolean matches(HttpServerExchange exchange) { - if (conditions == null || conditions.isEmpty()) { - return false; - } return conditions.stream().anyMatch(c -> c.matches(exchange)); } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java index fe4fe1f..e8b56ee 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathStartsWithCondition.java @@ -19,7 +19,7 @@ class PathStartsWithCondition implements ProxyExchangeInterceptorCondition { @Override public boolean matches(HttpServerExchange exchange) { if (StringUtils.isBlank(prefix)) { - return false; + return true; } return exchange.getRequestPath().startsWith(prefix); } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/condition/ConditionsTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/condition/ConditionsTest.java new file mode 100644 index 0000000..c93747a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/condition/ConditionsTest.java @@ -0,0 +1,247 @@ +package me.cxdev.commerce.proxy.interceptor.condition; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.CookieImpl; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + +@ExtendWith(MockitoExtension.class) +class ConditionsTest { + + @Mock + private HttpServerExchange exchangeMock; + + @BeforeEach + void setUp() { + // Reset mock behavior before each test if needed + } + + // --- Logical Operators (AND, OR, NOT) Edge Cases --- + + @Test + void testNotCondition() { + when(exchangeMock.getRequestPath()).thenReturn("/occ/v2/"); + + ProxyExchangeInterceptorCondition isOcc = Conditions.pathStartsWith("/occ"); + ProxyExchangeInterceptorCondition isNotOcc = isOcc.not(); // Using default method from interface + ProxyExchangeInterceptorCondition isNotOccViaFactory = Conditions.not(isOcc); + + assertTrue(isOcc.matches(exchangeMock)); + assertFalse(isNotOcc.matches(exchangeMock)); + assertFalse(isNotOccViaFactory.matches(exchangeMock)); + } + + @Test + void testAndCondition() { + when(exchangeMock.getRequestPath()).thenReturn("/smartedit/"); + + HeaderMap headers = new HeaderMap(); + headers.add(new HttpString("Authorization"), "Bearer token123"); + when(exchangeMock.getRequestHeaders()).thenReturn(headers); + + ProxyExchangeInterceptorCondition isSmartEdit = Conditions.pathStartsWith("/smartedit"); + ProxyExchangeInterceptorCondition hasAuth = Conditions.hasHeader("Authorization"); + ProxyExchangeInterceptorCondition isOcc = Conditions.pathStartsWith("/occ"); + + // Test Factory method + ProxyExchangeInterceptorCondition smartEditAndAuth = Conditions.and(isSmartEdit, hasAuth); + assertTrue(smartEditAndAuth.matches(exchangeMock), "Both conditions are true"); + + // Test Default Interface method chaining + ProxyExchangeInterceptorCondition chainedFailing = isSmartEdit.and(isOcc); + assertFalse(chainedFailing.matches(exchangeMock), "One condition is false, should be false"); + } + + @Test + void testOrCondition() { + when(exchangeMock.getRequestPath()).thenReturn("/occ/v2/"); + + ProxyExchangeInterceptorCondition isSmartEdit = Conditions.pathStartsWith("/smartedit"); + ProxyExchangeInterceptorCondition isOcc = Conditions.pathStartsWith("/occ"); + + // Test Factory method + ProxyExchangeInterceptorCondition occOrSmartEdit = Conditions.or(isSmartEdit, isOcc); + assertTrue(occOrSmartEdit.matches(exchangeMock), "One condition is true, should be true"); + + // Test Default Interface method chaining + ProxyExchangeInterceptorCondition chainedFailing = Conditions.pathStartsWith("/hac") + .or(Conditions.pathStartsWith("/backoffice")); + assertFalse(chainedFailing.matches(exchangeMock), "Both conditions are false, should be false"); + } + + @Test + void testComplexChaining() { + // Simulating: /occ request WITH an Authorization header + when(exchangeMock.getRequestPath()).thenReturn("/occ/v2/users"); + + HeaderMap headers = new HeaderMap(); + headers.add(new HttpString("Authorization"), "Bearer xyz"); + when(exchangeMock.getRequestHeaders()).thenReturn(headers); + + ProxyExchangeInterceptorCondition isOcc = Conditions.pathStartsWith("/occ"); + ProxyExchangeInterceptorCondition isSmartEdit = Conditions.pathStartsWith("/smartedit"); + ProxyExchangeInterceptorCondition hasAuth = Conditions.hasHeader("Authorization"); + + // (isOcc OR isSmartEdit) AND (NOT hasAuth) + ProxyExchangeInterceptorCondition complexCondition = isOcc.or(isSmartEdit).and(hasAuth.not()); + + // Should be false because hasAuth is true, so hasAuth.not() is false + assertFalse(complexCondition.matches(exchangeMock), "Complex chain should evaluate correctly"); + } + + @Test + void testLogicalConditionsWithNullOrEmpty() { + assertFalse(Conditions.and((ProxyExchangeInterceptorCondition[]) null).matches(exchangeMock), "Null AND should be false"); + assertFalse(Conditions.and(new ProxyExchangeInterceptorCondition[0]).matches(exchangeMock), "Empty AND should be false"); + + assertFalse(Conditions.or((ProxyExchangeInterceptorCondition[]) null).matches(exchangeMock), "Null OR should be false"); + assertFalse(Conditions.or(new ProxyExchangeInterceptorCondition[0]).matches(exchangeMock), "Empty OR should be false"); + + assertFalse(Conditions.not(null).matches(exchangeMock), "Null NOT should be false"); + } + + @Test + void testLogicalConditionsWithSingleElement() { + // Wir nehmen eine beliebige Condition als Dummy + ProxyExchangeInterceptorCondition singleCondition = Conditions.always(); + + // Rufen die Factory mit genau einem Element auf + ProxyExchangeInterceptorCondition andResult = Conditions.and(singleCondition); + ProxyExchangeInterceptorCondition orResult = Conditions.or(singleCondition); + + // Prüfen auf exakte Speicherreferenz (Identity) + assertSame(singleCondition, andResult, "AND with a single condition should return the condition itself"); + assertSame(singleCondition, orResult, "OR with a single condition should return the condition itself"); + } + + // --- Cookie Condition --- + + @Test + void testCookieExists() { + when(exchangeMock.getRequestCookie("cxdevproxy_user_id")).thenReturn(new CookieImpl("cxdevproxy_user_id", "admin")); + when(exchangeMock.getRequestCookie("missing_cookie")).thenReturn(null); + + assertTrue(Conditions.hasCookie("cxdevproxy_user_id").matches(exchangeMock)); + assertFalse(Conditions.hasCookie("missing_cookie").matches(exchangeMock)); + + // Edge cases + assertFalse(Conditions.hasCookie(null).matches(exchangeMock)); + assertFalse(Conditions.hasCookie("").matches(exchangeMock)); + } + + // --- Header Condition --- + + @Test + void testHeaderExists() { + HeaderMap headers = new HeaderMap(); + headers.add(new HttpString("Authorization"), "Bearer token"); + when(exchangeMock.getRequestHeaders()).thenReturn(headers); + + assertTrue(Conditions.hasHeader("Authorization").matches(exchangeMock)); + assertFalse(Conditions.hasHeader("Accept").matches(exchangeMock)); + + // Edge cases for null/empty/blank + assertFalse(Conditions.hasHeader(null).matches(exchangeMock), "Null header should be false"); + assertFalse(Conditions.hasHeader("").matches(exchangeMock), "Empty header should be false"); + assertFalse(Conditions.hasHeader(" ").matches(exchangeMock), "Blank header should be false"); + } + + // --- HTTP Method Condition --- + + @Test + void testHttpMethodCondition() { + when(exchangeMock.getRequestMethod()).thenReturn(new HttpString("POST")); + + assertTrue(Conditions.isMethod("POST").matches(exchangeMock)); + assertTrue(Conditions.isMethod("post").matches(exchangeMock), "Should be case-insensitive"); + assertFalse(Conditions.isMethod("GET").matches(exchangeMock)); + + // Edge cases + assertFalse(Conditions.isMethod(null).matches(exchangeMock)); + assertFalse(Conditions.isMethod("").matches(exchangeMock)); + } + + // --- Path Conditions (StartsWith, Ant, Regex) --- + + @Test + void testPathStartsWith() { + lenient().when(exchangeMock.getRequestPath()).thenReturn("/occ/v2/electronics"); + + assertTrue(Conditions.pathStartsWith("/occ").matches(exchangeMock)); + assertFalse(Conditions.pathStartsWith("/backoffice").matches(exchangeMock)); + + // Edge cases for null/empty/blank -> should be TRUE according to logic + assertTrue(Conditions.pathStartsWith(null).matches(exchangeMock), "Null prefix should be true"); + assertTrue(Conditions.pathStartsWith("").matches(exchangeMock), "Empty prefix should be true"); + assertTrue(Conditions.pathStartsWith(" ").matches(exchangeMock), "Blank prefix should be true"); + } + + @Test + void testPathAntMatcherCondition() { + lenient().when(exchangeMock.getRequestPath()).thenReturn("/occ/v2/electronics/users/current"); + + assertTrue(Conditions.pathMatches("/occ/v2/**").matches(exchangeMock)); + assertTrue(Conditions.pathMatches("/**/users/*").matches(exchangeMock)); + assertFalse(Conditions.pathMatches("/backoffice/**").matches(exchangeMock)); + + // Edge cases + assertFalse(Conditions.pathMatches(null).matches(exchangeMock)); + assertFalse(Conditions.pathMatches("").matches(exchangeMock)); + } + + @Test + void testPathRegexCondition() { + lenient().when(exchangeMock.getRequestPath()).thenReturn("/occ/v2/electronics/users/current"); + + assertTrue(Conditions.pathRegexMatches("^/occ/v[0-9]+.*").matches(exchangeMock)); + assertFalse(Conditions.pathRegexMatches("^/backoffice/.*").matches(exchangeMock)); + + // Edge cases + assertFalse(Conditions.pathRegexMatches(null).matches(exchangeMock)); + assertFalse(Conditions.pathRegexMatches("").matches(exchangeMock)); + } + + // --- Query Parameter Condition --- + + @Test + void testQueryParameterExists() { + Map> queryParams = new HashMap<>(); + Deque values = new ArrayDeque<>(); + values.add("FULL"); + queryParams.put("fields", values); + + when(exchangeMock.getQueryParameters()).thenReturn(queryParams); + + assertTrue(Conditions.hasParameter("fields").matches(exchangeMock)); + assertFalse(Conditions.hasParameter("lang").matches(exchangeMock)); + + // Edge cases + assertFalse(Conditions.hasParameter(null).matches(exchangeMock)); + assertFalse(Conditions.hasParameter("").matches(exchangeMock)); + } + + // --- Static Conditions --- + + @Test + void testStaticCondition() { + assertTrue(Conditions.always().matches(exchangeMock)); + assertFalse(Conditions.never().matches(exchangeMock)); + } +} From b38b48c88c3ee0d4dce22f337c87fb2066e250aa Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 13:37:49 +0100 Subject: [PATCH 07/25] add tests for proxy interceptor builder --- .../proxy/interceptor/ProxyInterceptor.java | 15 +- .../interceptor/ProxyInterceptorTest.java | 166 ++++++++++++++++++ 2 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java index 6a2e3ed..0c463d9 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java @@ -21,17 +21,17 @@ public class ProxyInterceptor implements ProxyExchangeInterceptor { private static final Logger LOG = LoggerFactory.getLogger(ProxyInterceptor.class); private List conditions; - private List delegates; + private List interceptors; // If true, acts as AND. If false, acts as OR. private boolean requireAllConditions = true; private ProxyInterceptor( List conditions, - List delegates, + List interceptors, boolean requireAllConditions) { this.conditions = List.copyOf(conditions); - this.delegates = List.copyOf(delegates); + this.interceptors = List.copyOf(interceptors); this.requireAllConditions = requireAllConditions; } @@ -43,7 +43,7 @@ private ProxyInterceptor( */ @Override public void apply(HttpServerExchange exchange) { - if (conditions == null || conditions.isEmpty() || delegates == null || delegates.isEmpty()) { + if (conditions == null || conditions.isEmpty() || interceptors == null || interceptors.isEmpty()) { return; } @@ -52,8 +52,8 @@ public void apply(HttpServerExchange exchange) { : conditions.stream().anyMatch(c -> c.matches(exchange)); if (match) { - LOG.debug("Conditions met. Executing {} delegate handler(s) for {}", delegates.size(), exchange.getRequestPath()); - for (ProxyExchangeInterceptor delegate : delegates) { + LOG.debug("Conditions met. Executing {} delegate handler(s) for {}", interceptors.size(), exchange.getRequestPath()); + for (ProxyExchangeInterceptor delegate : interceptors) { delegate.apply(exchange); } } @@ -80,7 +80,8 @@ public Builder requireAll(boolean value) { } public ProxyInterceptor perform(ProxyExchangeInterceptor... interceptor) { - return new ProxyInterceptor(this.conditions, Arrays.asList(interceptor), this.requireAllConditions); + List interceptorAsList = interceptor != null ? Arrays.asList(interceptor) : List.of(); + return new ProxyInterceptor(this.conditions, interceptorAsList, this.requireAllConditions); } private Builder() { diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java new file mode 100644 index 0000000..26e2499 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java @@ -0,0 +1,166 @@ +package me.cxdev.commerce.proxy.interceptor; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.undertow.server.HttpServerExchange; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ProxyInterceptorTest { + + @Mock + private HttpServerExchange exchangeMock; + + @Mock + private ProxyExchangeInterceptorCondition cond1; + + @Mock + private ProxyExchangeInterceptorCondition cond2; + + @Mock + private ProxyExchangeInterceptor delegate1; + + @Mock + private ProxyExchangeInterceptor delegate2; + + // --- 1. Edge Cases & Fail-Safes --- + + @Test + void testApplyWithEmptyConditionsOrDelegates() throws Exception { + // Test empty setup + ProxyInterceptor emptyInterceptor = ProxyInterceptor.interceptor().perform(); + emptyInterceptor.apply(exchangeMock); + verifyNoInteractions(exchangeMock); + + // Test with conditions but no delegates + ProxyInterceptor noDelegates = ProxyInterceptor.interceptor() + .constrainedBy(cond1) + .perform(); + noDelegates.apply(exchangeMock); + verifyNoInteractions(cond1, exchangeMock); + + // Test null safety in builder + ProxyInterceptor nullSafety = ProxyInterceptor.interceptor() + .constrainedBy((ProxyExchangeInterceptorCondition[]) null) + .perform((ProxyExchangeInterceptor[]) null); + nullSafety.apply(exchangeMock); + verifyNoInteractions(exchangeMock); + } + + // --- 2. AND Logic (requireAllConditions = true) --- + + @Test + void testApplyRequireAllTrue_AllMatch() throws Exception { + when(cond1.matches(exchangeMock)).thenReturn(true); + when(cond2.matches(exchangeMock)).thenReturn(true); + + ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + .constrainedBy(cond1, cond2) + .requireAll(true) // default, but explicit for test + .perform(delegate1, delegate2); + + interceptor.apply(exchangeMock); + + // Both conditions must be checked + verify(cond1).matches(exchangeMock); + verify(cond2).matches(exchangeMock); + + // Both delegates must be executed + verify(delegate1, times(1)).apply(exchangeMock); + verify(delegate2, times(1)).apply(exchangeMock); + } + + @Test + void testApplyRequireAllTrue_OneFails() throws Exception { + when(cond1.matches(exchangeMock)).thenReturn(true); + when(cond2.matches(exchangeMock)).thenReturn(false); + + ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + .constrainedBy(cond1, cond2) + .perform(delegate1); + + interceptor.apply(exchangeMock); + + // cond1 was true, cond2 was false -> match fails + // Delegate must NEVER be called + verify(delegate1, never()).apply(exchangeMock); + } + + // --- 3. OR Logic (requireAllConditions = false) --- + + @Test + void testApplyRequireAllFalse_OneMatches() throws Exception { + // Set first condition to false, second to true + when(cond1.matches(exchangeMock)).thenReturn(false); + when(cond2.matches(exchangeMock)).thenReturn(true); + + ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + .constrainedBy(cond1, cond2) + .requireAll(false) // Act as OR + .perform(delegate1); + + interceptor.apply(exchangeMock); + + // Because it's OR and cond2 is true, it should execute the delegate + verify(delegate1, times(1)).apply(exchangeMock); + } + + @Test + void testApplyRequireAllFalse_NoneMatches() throws Exception { + when(cond1.matches(exchangeMock)).thenReturn(false); + when(cond2.matches(exchangeMock)).thenReturn(false); + + ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + .constrainedBy(cond1, cond2) + .requireAll(false) + .perform(delegate1); + + interceptor.apply(exchangeMock); + + // Neither condition matched -> delegate must NEVER be called + verify(delegate1, never()).apply(exchangeMock); + } + + // --- 4. Short-Circuiting Optimization --- + + @Test + void testAndLogicShortCircuits() throws Exception { + // If cond1 is false in an AND logic, cond2 should not even be evaluated + when(cond1.matches(exchangeMock)).thenReturn(false); + + ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + .constrainedBy(cond1, cond2) + .perform(delegate1); + + interceptor.apply(exchangeMock); + + verify(cond1).matches(exchangeMock); + verify(cond2, never()).matches(exchangeMock); // Stream.allMatch short-circuits! + verifyNoInteractions(delegate1); + } + + @Test + void testOrLogicShortCircuits() throws Exception { + // If cond1 is true in an OR logic, cond2 should not even be evaluated + when(cond1.matches(exchangeMock)).thenReturn(true); + + ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + .constrainedBy(cond1, cond2) + .requireAll(false) + .perform(delegate1); + + interceptor.apply(exchangeMock); + + verify(cond1).matches(exchangeMock); + verify(cond2, never()).matches(exchangeMock); // Stream.anyMatch short-circuits! + verify(delegate1).apply(exchangeMock); + } +} From 45527fc786c92b792983a490b142f5d837a3b57e Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 13:57:48 +0100 Subject: [PATCH 08/25] add tests for JWT injector interceptor --- .../interceptor/JwtInjectorInterceptor.java | 5 +- .../JwtInjectorInterceptorTest.java | 120 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java index 3312caa..fd42e5c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java @@ -33,10 +33,11 @@ public class JwtInjectorInterceptor implements ProxyExchangeInterceptor { */ @Override public void apply(HttpServerExchange exchange) { - Cookie userIdCookie = exchange.getRequestCookie(USER_ID_COOKIE_NAME); Cookie userTypeCookie = exchange.getRequestCookie(USER_TYPE_COOKIE_NAME); + Cookie userIdCookie = exchange.getRequestCookie(USER_ID_COOKIE_NAME); - if (userIdCookie != null && StringUtils.isNotBlank(userIdCookie.getValue())) { + if (userTypeCookie != null && StringUtils.isNotBlank(userTypeCookie.getValue()) && + userIdCookie != null && StringUtils.isNotBlank(userIdCookie.getValue())) { String userType = userTypeCookie.getValue(); String userId = userIdCookie.getValue(); String token = jwtTokenService.getOrGenerateToken(userType, userId); diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java new file mode 100644 index 0000000..73e5a7c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java @@ -0,0 +1,120 @@ +package me.cxdev.commerce.proxy.interceptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashSet; +import java.util.Set; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.Cookie; +import io.undertow.server.handlers.CookieImpl; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import me.cxdev.commerce.jwt.service.CxJwtTokenService; + +@ExtendWith(MockitoExtension.class) +class JwtInjectorInterceptorTest { + + @Mock + private CxJwtTokenService jwtTokenServiceMock; + + @Mock + private HttpServerExchange exchangeMock; + + @InjectMocks + private JwtInjectorInterceptor interceptor; + + private HeaderMap requestHeaders; + private HeaderMap responseHeaders; + private Set requestCookies; + + @BeforeEach + void setUp() { + // Undertow Maps initialisieren + requestHeaders = new HeaderMap(); + responseHeaders = new HeaderMap(); + requestCookies = new LinkedHashSet<>(); + + // Lenient Stubbing, da nicht jeder Test beide Maps zwingend benötigt + Mockito.lenient().when(exchangeMock.getRequestHeaders()).thenReturn(requestHeaders); + Mockito.lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + Mockito.lenient().when(exchangeMock.requestCookies()).thenReturn(requestCookies); + Mockito.lenient().when(exchangeMock.getRequestCookie(anyString())).thenCallRealMethod(); + } + + @Test + void testApply_InjectsTokenSuccessfully() throws Exception { + // 1. Setup: Cookies sind vorhanden + requestCookies.add(new CookieImpl("cxdevproxy_user_id", "customer@cxdev.me")); + requestCookies.add(new CookieImpl("cxdevproxy_user_type", "customer")); + + // 2. Setup: TokenService liefert ein valides Token + when(jwtTokenServiceMock.getOrGenerateToken("customer", "customer@cxdev.me")) + .thenReturn("mocked.jwt.token"); + + // 3. Ausführung + interceptor.apply(exchangeMock); + + // 4. Assert: Der Header muss korrekt mit "Bearer " Präfix gesetzt sein + assertEquals("Bearer mocked.jwt.token", requestHeaders.getFirst(Headers.AUTHORIZATION), + "Authorization header should contain the Bearer token"); + } + + @Test + void testApply_MissingUserIdCookie_DoesNothing() throws Exception { + // Nur der Typ-Cookie ist da, aber keine ID + requestCookies.add(new CookieImpl("cxdevproxy_user_type", "customer")); + + interceptor.apply(exchangeMock); + + // TokenService darf nicht aufgerufen werden + verify(jwtTokenServiceMock, never()).getOrGenerateToken(anyString(), anyString()); + + // Kein Header darf gesetzt werden + assertFalse(requestHeaders.contains(Headers.AUTHORIZATION), + "Authorization header should not be set if user_id is missing"); + } + + @Test + void testApply_MissingUserTypeCookie_DoesNothing() throws Exception { + // Nur der ID-Cookie ist da, aber kein Typ + requestCookies.add(new CookieImpl("cxdevproxy_user_id", "customer@cxdev.me")); + + interceptor.apply(exchangeMock); + + // TokenService darf nicht aufgerufen werden + verify(jwtTokenServiceMock, never()).getOrGenerateToken(anyString(), anyString()); + + // Kein Header darf gesetzt werden + assertFalse(requestHeaders.contains(Headers.AUTHORIZATION), + "Authorization header should not be set if user_type is missing"); + } + + @Test + void testApply_TokenServiceReturnsNull_DoesNothing() throws Exception { + // Cookies sind vorhanden + requestCookies.add(new CookieImpl("cxdevproxy_user_id", "invalid@cxdev.me")); + requestCookies.add(new CookieImpl("cxdevproxy_user_type", "customer")); + + // TokenService schlägt fehl / findet kein Template und gibt null zurück + when(jwtTokenServiceMock.getOrGenerateToken("customer", "invalid@cxdev.me")).thenReturn(null); + + interceptor.apply(exchangeMock); + + // Kein Header darf gesetzt werden, da kein Token generiert wurde + assertFalse(requestHeaders.contains(Headers.AUTHORIZATION), + "Authorization header should not be set if generated token is null"); + } +} From bd54087106c363c9b87cd73db57defd56d0df83e Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 15:07:42 +0100 Subject: [PATCH 09/25] introduce prefix convention for interceptor beans --- .../config/cxdevproxy-interceptor-spring.xml | 8 +++----- .../proxy/livecycle/GroovyRuleEngineService.java | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml index 554e8a6..9b03cf7 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml @@ -3,19 +3,17 @@ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> - + - - - + - + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java index ad3e052..720b7b8 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java @@ -39,6 +39,7 @@ public class GroovyRuleEngineService implements ApplicationContextAware, ResourceLoaderAware { private static final Logger LOG = LoggerFactory.getLogger(GroovyRuleEngineService.class); private static final String CONDITION_BEAN_PREFIX = "cxdevproxyCondition"; + private static final String INTERCEPTOR_BEAN_PREFIX = "cxdevproxyInterceptor"; private ApplicationContext applicationContext; private ResourceLoader resourceLoader; @@ -60,8 +61,15 @@ private void initGroovyShell() { Map handlers = applicationContext.getBeansOfType(ProxyExchangeInterceptor.class); for (Map.Entry entry : handlers.entrySet()) { - binding.setVariable(entry.getKey(), entry.getValue()); - LOG.debug("Bound Spring handler bean '{}' to Groovy Context", entry.getKey()); + String beanName = entry.getKey(); + String bindingName = beanName; + if (beanName.startsWith(INTERCEPTOR_BEAN_PREFIX) && beanName.length() > INTERCEPTOR_BEAN_PREFIX.length()) { + String stripped = beanName.substring(INTERCEPTOR_BEAN_PREFIX.length()); + bindingName = Character.toLowerCase(stripped.charAt(0)) + stripped.substring(1); + } + + binding.setVariable(bindingName, entry.getValue()); + LOG.debug("Bound Spring interceptor bean '{}' as '{}' to Groovy Context", beanName, bindingName); } Map conditions = applicationContext.getBeansOfType(ProxyExchangeInterceptorCondition.class); @@ -78,7 +86,8 @@ private void initGroovyShell() { ImportCustomizer importCustomizer = new ImportCustomizer(); importCustomizer.addStarImports("me.cxdev.commerce.proxy.interceptor"); - importCustomizer.addStaticStars("me.cxdev.commerce.proxy.condition.Conditions"); + importCustomizer.addStaticStars("me.cxdev.commerce.proxy.interceptor.ProxyInterceptor"); + importCustomizer.addStaticStars("me.cxdev.commerce.proxy.interceptor.condition.Conditions"); CompilerConfiguration config = new CompilerConfiguration(); config.addCompilationCustomizers(importCustomizer); From b7680d2ac84a8b05e37c22116e049aa3cb1a68a5 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 15:59:17 +0100 Subject: [PATCH 10/25] introduce interceptors DSL and add tests for interceptors --- .../interceptor/CorsInjectorInterceptor.java | 4 +- .../proxy/interceptor/Interceptors.java | 72 ++++++++++ .../interceptor/NetworkDelayInterceptor.java | 40 +++--- .../proxy/interceptor/ProxyInterceptor.java | 35 +---- .../StaticResponseInterceptor.java | 31 ++-- .../livecycle/GroovyRuleEngineService.java | 2 +- .../CorsInjectorInterceptorTest.java | 111 +++++++++++++++ .../ForwardedHeadersInterceptorTest.java | 133 ++++++++++++++++++ ...rceptorTest.java => InterceptorsTest.java} | 36 ++--- .../JwtInjectorInterceptorTest.java | 1 - .../NetworkDelayInterceptorTest.java | 75 ++++++++++ .../StaticResponseInterceptorTest.java | 96 +++++++++++++ 12 files changed, 549 insertions(+), 87 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/Interceptors.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptorTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptorTest.java rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/{ProxyInterceptorTest.java => InterceptorsTest.java} (81%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptorTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptorTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java index 7448de2..7f81114 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java @@ -6,6 +6,8 @@ import org.apache.commons.lang3.StringUtils; +import jakarta.ws.rs.HttpMethod; + /** * Injects configurable CORS (Cross-Origin Resource Sharing) headers into the response. * Acts as an "Auto-CORS" modifier by dynamically echoing the incoming 'Origin' header @@ -39,7 +41,7 @@ public void apply(HttpServerExchange exchange) { } // If it's a preflight OPTIONS request, answer it immediately - if (exchange.getRequestMethod().toString().equalsIgnoreCase("OPTIONS")) { + if (HttpMethod.OPTIONS.equalsIgnoreCase(exchange.getRequestMethod().toString())) { exchange.setStatusCode(200); exchange.endExchange(); } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/Interceptors.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/Interceptors.java new file mode 100644 index 0000000..0be3067 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/Interceptors.java @@ -0,0 +1,72 @@ +package me.cxdev.commerce.proxy.interceptor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import jakarta.ws.rs.core.MediaType; + +public final class Interceptors { + private static final int DEFAULT_STATUS_CODE = 200; + + public static ProxyExchangeInterceptor htmlResponse(String responseBody) { + return htmlResponse(DEFAULT_STATUS_CODE, responseBody); + } + + public static ProxyExchangeInterceptor htmlResponse(int statusCode, String responseBody) { + return staticResponse(statusCode, MediaType.TEXT_HTML, responseBody); + } + + public static ProxyExchangeInterceptor jsonResponse(String responseBody) { + return jsonResponse(DEFAULT_STATUS_CODE, responseBody); + } + + public static ProxyExchangeInterceptor jsonResponse(int statusCode, String responseBody) { + return staticResponse(statusCode, MediaType.APPLICATION_JSON, responseBody); + } + + public static ProxyExchangeInterceptor staticResponse(int statusCode, String contentType, String responseBody) { + String contentTypeWithFallback = StringUtils.defaultIfBlank(contentType, MediaType.TEXT_PLAIN); + String responseBodyWithFallback = StringUtils.defaultIfBlank(responseBody, ""); + return new StaticResponseInterceptor(statusCode, contentTypeWithFallback, responseBodyWithFallback); + } + + public static ProxyExchangeInterceptor networkDelay(String delay) { + return new NetworkDelayInterceptor(delay); + } + + public static ProxyExchangeInterceptor networkDelay(String minDelay, String maxDelay) { + return new NetworkDelayInterceptor(minDelay, maxDelay); + } + + public static Builder interceptor() { + return new Builder(); + } + + public static class Builder { + private final List conditions = new ArrayList<>(); + private boolean requireAllConditions = true; + + public Builder constrainedBy(ProxyExchangeInterceptorCondition... conditions) { + if (conditions != null) { + this.conditions.addAll(Arrays.asList(conditions)); + } + return this; + } + + public Builder requireAll(boolean value) { + this.requireAllConditions = value; + return this; + } + + public ProxyExchangeInterceptor perform(ProxyExchangeInterceptor... interceptor) { + List interceptorAsList = interceptor != null ? Arrays.asList(interceptor) : List.of(); + return new ProxyInterceptor(this.conditions, interceptorAsList, this.requireAllConditions); + } + + private Builder() { + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java index a47d015..ca1f8ec 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java @@ -7,6 +7,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import me.cxdev.commerce.proxy.util.TimeUtils; + /** * Artificially delays the request processing to simulate network latency * or a slow backend environment. Perfect for testing frontend loading states. @@ -15,11 +17,27 @@ * Note: Uses Thread.sleep() which blocks the current worker thread. *

*/ -public class NetworkDelayInterceptor implements ProxyExchangeInterceptor { +class NetworkDelayInterceptor implements ProxyExchangeInterceptor { private static final Logger LOG = LoggerFactory.getLogger(NetworkDelayInterceptor.class); + private static final long DEFAULT_DELAY_INMILLIS = 1000L; + + private long minDelayInMillis; + private long maxDelayInMillis; + + /** + * Convenience constructor to assign a fixed delay (sets both min and max to the same value). + */ + NetworkDelayInterceptor(String delay) { + this.minDelayInMillis = TimeUtils.parseIntervalToMillis(delay, DEFAULT_DELAY_INMILLIS, "Network delay interceptor interval"); + this.maxDelayInMillis = this.minDelayInMillis; + } - private long minDelayInMillis = 1000; - private long maxDelayInMillis = 1000; + NetworkDelayInterceptor(String minDelay, String maxDelay) { + this.minDelayInMillis = TimeUtils.parseIntervalToMillis(minDelay, DEFAULT_DELAY_INMILLIS, "Network minimum delay interceptor interval"); + ; + this.maxDelayInMillis = TimeUtils.parseIntervalToMillis(maxDelay, DEFAULT_DELAY_INMILLIS, "Network maximum delay interceptor interval"); + ; + } @Override public void apply(HttpServerExchange exchange) { @@ -50,20 +68,4 @@ private long calculateDelay() { } return ThreadLocalRandom.current().nextLong(minDelayInMillis, maxDelayInMillis + 1); } - - public void setMinDelayInMillis(long minDelayInMillis) { - this.minDelayInMillis = minDelayInMillis; - } - - public void setMaxDelayInMillis(long maxDelayInMillis) { - this.maxDelayInMillis = maxDelayInMillis; - } - - /** - * Convenience setter to assign a fixed delay (sets both min and max to the same value). - */ - public void setDelayInMillis(long delayInMillis) { - this.minDelayInMillis = delayInMillis; - this.maxDelayInMillis = delayInMillis; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java index 0c463d9..dea9178 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java @@ -1,7 +1,5 @@ package me.cxdev.commerce.proxy.interceptor; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import io.undertow.server.HttpServerExchange; @@ -17,7 +15,7 @@ * This can be changed to OR logic by setting {@code requireAllConditions} to {@code false}. *

*/ -public class ProxyInterceptor implements ProxyExchangeInterceptor { +class ProxyInterceptor implements ProxyExchangeInterceptor { private static final Logger LOG = LoggerFactory.getLogger(ProxyInterceptor.class); private List conditions; @@ -26,7 +24,7 @@ public class ProxyInterceptor implements ProxyExchangeInterceptor { // If true, acts as AND. If false, acts as OR. private boolean requireAllConditions = true; - private ProxyInterceptor( + ProxyInterceptor( List conditions, List interceptors, boolean requireAllConditions) { @@ -58,33 +56,4 @@ public void apply(HttpServerExchange exchange) { } } } - - public static Builder interceptor() { - return new Builder(); - } - - public static class Builder { - private final List conditions = new ArrayList<>(); - private boolean requireAllConditions = true; - - public Builder constrainedBy(ProxyExchangeInterceptorCondition... conditions) { - if (conditions != null) { - this.conditions.addAll(Arrays.asList(conditions)); - } - return this; - } - - public Builder requireAll(boolean value) { - this.requireAllConditions = value; - return this; - } - - public ProxyInterceptor perform(ProxyExchangeInterceptor... interceptor) { - List interceptorAsList = interceptor != null ? Arrays.asList(interceptor) : List.of(); - return new ProxyInterceptor(this.conditions, interceptorAsList, this.requireAllConditions); - } - - private Builder() { - } - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java index 0fa2d82..866c929 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java @@ -3,15 +3,26 @@ import io.undertow.server.HttpServerExchange; import io.undertow.util.Headers; +import org.apache.commons.lang3.StringUtils; + /** * Short-circuits the request and returns a predefined status code and payload. * Useful for mocking endpoints that do not yet exist in the backend API, * or for simulating specific error states (e.g., forcing a 500 Internal Server Error). */ -public class StaticResponseInterceptor implements ProxyExchangeInterceptor { - private int statusCode = 200; - private String contentType = "application/json"; - private String responseBody = "{}"; +class StaticResponseInterceptor implements ProxyExchangeInterceptor { + private int statusCode; + private String contentType; + private String responseBody; + + StaticResponseInterceptor(int statusCode, String contentType, String responseBody) { + assert StringUtils.isNotBlank(contentType); + assert responseBody != null; + + this.statusCode = statusCode; + this.contentType = contentType; + this.responseBody = responseBody; + } @Override public void apply(HttpServerExchange exchange) { @@ -22,16 +33,4 @@ public void apply(HttpServerExchange exchange) { exchange.getResponseSender().send(responseBody); exchange.endExchange(); } - - public void setStatusCode(int statusCode) { - this.statusCode = statusCode; - } - - public void setContentType(String contentType) { - this.contentType = contentType; - } - - public void setResponseBody(String responseBody) { - this.responseBody = responseBody; - } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java index 720b7b8..97daa13 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java @@ -86,7 +86,7 @@ private void initGroovyShell() { ImportCustomizer importCustomizer = new ImportCustomizer(); importCustomizer.addStarImports("me.cxdev.commerce.proxy.interceptor"); - importCustomizer.addStaticStars("me.cxdev.commerce.proxy.interceptor.ProxyInterceptor"); + importCustomizer.addStaticStars("me.cxdev.commerce.proxy.interceptor.Interceptors"); importCustomizer.addStaticStars("me.cxdev.commerce.proxy.interceptor.condition.Conditions"); CompilerConfiguration config = new CompilerConfiguration(); diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptorTest.java new file mode 100644 index 0000000..eb11cc0 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptorTest.java @@ -0,0 +1,111 @@ +package me.cxdev.commerce.proxy.interceptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.ws.rs.HttpMethod; + +@ExtendWith(MockitoExtension.class) +class CorsInjectorInterceptorTest { + @Mock + private HttpServerExchange exchangeMock; + + private CorsInjectorInterceptor interceptor; + private HeaderMap requestHeaders; + private HeaderMap responseHeaders; + + private static final HttpString ALLOW_ORIGIN = new HttpString("Access-Control-Allow-Origin"); + private static final HttpString ALLOW_CREDENTIALS = new HttpString("Access-Control-Allow-Credentials"); + + @BeforeEach + void setUp() { + interceptor = new CorsInjectorInterceptor(); + + requestHeaders = new HeaderMap(); + responseHeaders = new HeaderMap(); + + lenient().when(exchangeMock.getRequestMethod()).thenReturn(HttpString.tryFromString(HttpMethod.OPTIONS)); + lenient().when(exchangeMock.getRequestHeaders()).thenReturn(requestHeaders); + lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + } + + @Test + void testApply_WithOriginHeader_InjectsCorsResponseHeaders() throws Exception { + requestHeaders.add(Headers.ORIGIN, "http://localhost:4200"); + + interceptor.apply(exchangeMock); + + assertEquals("http://localhost:4200", responseHeaders.getFirst(ALLOW_ORIGIN)); + assertNull(responseHeaders.getFirst(ALLOW_CREDENTIALS)); + assertTrue(responseHeaders.contains(new HttpString("Access-Control-Allow-Methods")), + "Methods should usually be handled by Spring/Hybris, unless explicitly set in proxy"); + } + + @Test + void testApply_WithoutOriginHeader_DoesNothing() throws Exception { + interceptor.apply(exchangeMock); + + assertFalse(responseHeaders.contains(ALLOW_ORIGIN)); + } + + @Test + void testApply_WithAllowCredentialsTrue() throws Exception { + interceptor.setAllowCredentials(true); + requestHeaders.add(Headers.ORIGIN, "https://local.cxdev.me:4200"); + + interceptor.apply(exchangeMock); + + assertEquals("https://local.cxdev.me:4200", responseHeaders.getFirst(ALLOW_ORIGIN)); + assertEquals("true", responseHeaders.getFirst(ALLOW_CREDENTIALS)); + } + + @Test + void testApply_WithEmptyMethodsAndHeaders_DoesNotSetThem() throws Exception { + requestHeaders.add(Headers.ORIGIN, "http://localhost:4200"); + + interceptor.setAllowedMethods(""); + interceptor.setAllowedHeaders(null); + interceptor.apply(exchangeMock); + + assertFalse(responseHeaders.contains(new HttpString("Access-Control-Allow-Methods")), + "Empty allowedMethods should not result in a header"); + assertFalse(responseHeaders.contains(new HttpString("Access-Control-Allow-Headers")), + "Null allowedHeaders should not result in a header"); + assertEquals("http://localhost:4200", responseHeaders.getFirst(ALLOW_ORIGIN)); + } + + @Test + void testApply_OptionsRequest_EndsExchangeForPreflight() throws Exception { + requestHeaders.add(Headers.ORIGIN, "http://localhost:4200"); + + Mockito.lenient().when(exchangeMock.getRequestMethod()) + .thenReturn(io.undertow.util.Methods.OPTIONS); + + interceptor.apply(exchangeMock); + + verify(exchangeMock).endExchange(); + } + + @Test + void testApply_NonOptionsRequest_DoesNotEndExchange() throws Exception { + requestHeaders.add(Headers.ORIGIN, "http://localhost:4200"); + Mockito.lenient().when(exchangeMock.getRequestMethod()) + .thenReturn(io.undertow.util.Methods.GET); + + interceptor.apply(exchangeMock); + + verify(exchangeMock, org.mockito.Mockito.never()).endExchange(); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptorTest.java new file mode 100644 index 0000000..a9da053 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ForwardedHeadersInterceptorTest.java @@ -0,0 +1,133 @@ +package me.cxdev.commerce.proxy.interceptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.lenient; + +import java.net.InetSocketAddress; + +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; +import io.undertow.util.HttpString; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ForwardedHeadersInterceptorTest { + @Mock + private HttpServerExchange exchangeMock; + + private ForwardedHeadersInterceptor interceptor; + private HeaderMap requestHeaders; + + @BeforeEach + void setUp() { + interceptor = new ForwardedHeadersInterceptor(); + + // Setup der Fallback-Properties (als kämen sie aus Spring) + interceptor.setServerHostname("fallback.local.cxdev.me"); + interceptor.setServerProtocol("https"); + interceptor.setServerPort(8080); + + requestHeaders = new HeaderMap(); + lenient().when(exchangeMock.getRequestHeaders()).thenReturn(requestHeaders); + + // Mock für X-Forwarded-For + InetSocketAddress sourceAddress = new InetSocketAddress("192.168.1.100", 54321); + lenient().when(exchangeMock.getSourceAddress()).thenReturn(sourceAddress); + } + + @Test + void testApply_HostHeaderWithValidPort() throws Exception { + requestHeaders.put(Headers.HOST, "my.custom.host:9002"); + + interceptor.apply(exchangeMock); + + assertEquals("my.custom.host", requestHeaders.getFirst(new HttpString("X-Forwarded-Host"))); + assertEquals("9002", requestHeaders.getFirst(new HttpString("X-Forwarded-Port"))); + assertEquals("192.168.1.100", requestHeaders.getFirst(new HttpString("X-Forwarded-For"))); + } + + @Test + void testApply_HostHeaderWithoutPort_HttpsFallback() throws Exception { + interceptor.setServerProtocol("https"); + requestHeaders.put(Headers.HOST, "my.custom.host"); + + interceptor.apply(exchangeMock); + + assertEquals("my.custom.host", requestHeaders.getFirst(new HttpString("X-Forwarded-Host"))); + // Fallback zu 443 bei HTTPS + assertEquals("443", requestHeaders.getFirst(new HttpString("X-Forwarded-Port"))); + } + + @Test + void testApply_HostHeaderWithoutPort_HttpFallback() throws Exception { + interceptor.setServerProtocol("http"); // Protokoll ändern + requestHeaders.put(Headers.HOST, "my.custom.host"); + + interceptor.apply(exchangeMock); + + assertEquals("my.custom.host", requestHeaders.getFirst(new HttpString("X-Forwarded-Host"))); + // Fallback zu 80 bei HTTP + assertEquals("80", requestHeaders.getFirst(new HttpString("X-Forwarded-Port"))); + } + + @Test + void testApply_HostHeaderWithInvalidPort_CatchesNumberFormatException() throws Exception { + interceptor.setServerProtocol("https"); + requestHeaders.put(Headers.HOST, "my.custom.host:invalid"); // Unparseable port + + interceptor.apply(exchangeMock); + + assertEquals("my.custom.host", requestHeaders.getFirst(new HttpString("X-Forwarded-Host"))); + // Exception gefangen -> Fallback zu Protokoll-Default (443) + assertEquals("443", requestHeaders.getFirst(new HttpString("X-Forwarded-Port"))); + } + + @Test + void testApply_MissingHostHeader_UsesConfiguredSpringDefaults() throws Exception { + // Wir setzen keinen Host-Header im Request + + interceptor.apply(exchangeMock); + + // Fallback zu den in setUp() konfigurierten Werten aus der XML + assertEquals("fallback.local.cxdev.me", requestHeaders.getFirst(new HttpString("X-Forwarded-Host"))); + assertEquals("8080", requestHeaders.getFirst(new HttpString("X-Forwarded-Port"))); + assertEquals("https", requestHeaders.getFirst(new HttpString("X-Forwarded-Proto"))); + } + + @Test + void testApply_WithExistingForwardedHeaders_AppendsToForwardedFor() throws Exception { + // Setup: Der Request kommt bereits durch einen anderen Proxy/Load Balancer + HttpString forwardedForHeader = new HttpString("X-Forwarded-For"); + HttpString forwardedHostHeader = new HttpString("X-Forwarded-Host"); + HttpString forwardedProtoHeader = new HttpString("X-Forwarded-Proto"); + HttpString forwardedPortHeader = new HttpString("X-Forwarded-Port"); + + requestHeaders.put(forwardedForHeader, "203.0.113.195"); + requestHeaders.put(forwardedHostHeader, "original.cxdev.me"); + requestHeaders.put(forwardedProtoHeader, "http"); + requestHeaders.put(forwardedPortHeader, "80"); + + // Ausführung + interceptor.apply(exchangeMock); + + // Assert 1: Die aktuelle Source-IP muss an die bestehende Kette angehängt werden + String resultingForwardedFor = requestHeaders.getFirst(forwardedForHeader); + assertEquals("203.0.113.195, 192.168.1.100", resultingForwardedFor, + "Existing X-Forwarded-For must be preserved and the new IP appended"); + + // Assert 2: Die anderen bestehenden Forwarded-Header dürfen nicht überschrieben werden, + // da sie den echten Client-Ursprung (Original Host/Proto) repräsentieren. + assertEquals("original.cxdev.me", requestHeaders.getFirst(forwardedHostHeader), + "Existing X-Forwarded-Host should be preserved"); + assertEquals("http", requestHeaders.getFirst(forwardedProtoHeader), + "Existing X-Forwarded-Proto should be preserved"); + assertEquals("80", requestHeaders.getFirst(forwardedPortHeader), + "Existing X-Forwarded-Port should be preserved"); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/InterceptorsTest.java similarity index 81% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/InterceptorsTest.java index 26e2499..a822529 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/ProxyInterceptorTest.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/InterceptorsTest.java @@ -1,21 +1,20 @@ package me.cxdev.commerce.proxy.interceptor; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import io.undertow.server.HttpServerExchange; +import io.undertow.util.HttpString; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) -class ProxyInterceptorTest { +import jakarta.ws.rs.HttpMethod; +@ExtendWith(MockitoExtension.class) +class InterceptorsTest { @Mock private HttpServerExchange exchangeMock; @@ -31,24 +30,29 @@ class ProxyInterceptorTest { @Mock private ProxyExchangeInterceptor delegate2; + @BeforeEach + void setUp() { + lenient().when(exchangeMock.getRequestMethod()).thenReturn(HttpString.tryFromString(HttpMethod.OPTIONS)); + } + // --- 1. Edge Cases & Fail-Safes --- @Test void testApplyWithEmptyConditionsOrDelegates() throws Exception { // Test empty setup - ProxyInterceptor emptyInterceptor = ProxyInterceptor.interceptor().perform(); + ProxyExchangeInterceptor emptyInterceptor = Interceptors.interceptor().perform(); emptyInterceptor.apply(exchangeMock); verifyNoInteractions(exchangeMock); // Test with conditions but no delegates - ProxyInterceptor noDelegates = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor noDelegates = Interceptors.interceptor() .constrainedBy(cond1) .perform(); noDelegates.apply(exchangeMock); verifyNoInteractions(cond1, exchangeMock); // Test null safety in builder - ProxyInterceptor nullSafety = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor nullSafety = Interceptors.interceptor() .constrainedBy((ProxyExchangeInterceptorCondition[]) null) .perform((ProxyExchangeInterceptor[]) null); nullSafety.apply(exchangeMock); @@ -62,7 +66,7 @@ void testApplyRequireAllTrue_AllMatch() throws Exception { when(cond1.matches(exchangeMock)).thenReturn(true); when(cond2.matches(exchangeMock)).thenReturn(true); - ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor interceptor = Interceptors.interceptor() .constrainedBy(cond1, cond2) .requireAll(true) // default, but explicit for test .perform(delegate1, delegate2); @@ -83,7 +87,7 @@ void testApplyRequireAllTrue_OneFails() throws Exception { when(cond1.matches(exchangeMock)).thenReturn(true); when(cond2.matches(exchangeMock)).thenReturn(false); - ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor interceptor = Interceptors.interceptor() .constrainedBy(cond1, cond2) .perform(delegate1); @@ -102,7 +106,7 @@ void testApplyRequireAllFalse_OneMatches() throws Exception { when(cond1.matches(exchangeMock)).thenReturn(false); when(cond2.matches(exchangeMock)).thenReturn(true); - ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor interceptor = Interceptors.interceptor() .constrainedBy(cond1, cond2) .requireAll(false) // Act as OR .perform(delegate1); @@ -118,7 +122,7 @@ void testApplyRequireAllFalse_NoneMatches() throws Exception { when(cond1.matches(exchangeMock)).thenReturn(false); when(cond2.matches(exchangeMock)).thenReturn(false); - ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor interceptor = Interceptors.interceptor() .constrainedBy(cond1, cond2) .requireAll(false) .perform(delegate1); @@ -136,7 +140,7 @@ void testAndLogicShortCircuits() throws Exception { // If cond1 is false in an AND logic, cond2 should not even be evaluated when(cond1.matches(exchangeMock)).thenReturn(false); - ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor interceptor = Interceptors.interceptor() .constrainedBy(cond1, cond2) .perform(delegate1); @@ -152,7 +156,7 @@ void testOrLogicShortCircuits() throws Exception { // If cond1 is true in an OR logic, cond2 should not even be evaluated when(cond1.matches(exchangeMock)).thenReturn(true); - ProxyInterceptor interceptor = ProxyInterceptor.interceptor() + ProxyExchangeInterceptor interceptor = Interceptors.interceptor() .constrainedBy(cond1, cond2) .requireAll(false) .perform(delegate1); diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java index 73e5a7c..bc84489 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java @@ -26,7 +26,6 @@ @ExtendWith(MockitoExtension.class) class JwtInjectorInterceptorTest { - @Mock private CxJwtTokenService jwtTokenServiceMock; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptorTest.java new file mode 100644 index 0000000..1597e6a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptorTest.java @@ -0,0 +1,75 @@ +package me.cxdev.commerce.proxy.interceptor; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.TimeUnit; + +import io.undertow.server.HttpServerExchange; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NetworkDelayInterceptorTest { + @Mock + private HttpServerExchange exchangeMock; + + @Test + @Timeout(value = 1, unit = TimeUnit.SECONDS) + void testApply_WithFixedDelay() throws Exception { + long delayMs = 50L; + ProxyExchangeInterceptor interceptor = Interceptors.networkDelay("50ms"); + + long start = System.currentTimeMillis(); + interceptor.apply(exchangeMock); + long duration = System.currentTimeMillis() - start; + + assertTrue(duration >= delayMs, "Execution should be delayed by at least " + delayMs + "ms"); + assertTrue(duration < (delayMs + 30), "Execution should not take significantly longer than the delay"); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.SECONDS) + void testApply_WithVariableDelay() throws Exception { + long minDelay = 30L; + long maxDelay = 80L; + ProxyExchangeInterceptor interceptor = Interceptors.networkDelay("30ms", "80ms"); + + long start = System.currentTimeMillis(); + interceptor.apply(exchangeMock); + long duration = System.currentTimeMillis() - start; + + assertTrue(duration >= minDelay, "Execution should be delayed by at least minDelay (" + minDelay + "ms)"); + assertTrue(duration < (maxDelay + 30), "Execution should not exceed maxDelay + buffer (" + maxDelay + "ms)"); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.SECONDS) + void testApply_WithNegativeValues_ShouldNotBlockForeverOrCrash() throws Exception { + ProxyExchangeInterceptor interceptor = Interceptors.networkDelay("-100ms", "-50ms"); + + long start = System.currentTimeMillis(); + interceptor.apply(exchangeMock); + long duration = System.currentTimeMillis() - start; + + assertTrue(duration < 20, "Negative delays should be handled gracefully without long blocking"); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.SECONDS) + void testApply_WithMinGreaterThanMax_UsesMinForBoth() throws Exception { + long minDelay = 80L; + long maxDelay = 30L; + ProxyExchangeInterceptor interceptor = Interceptors.networkDelay("80ms", "30ms"); + + long start = System.currentTimeMillis(); + interceptor.apply(exchangeMock); + long duration = System.currentTimeMillis() - start; + + assertTrue(duration >= minDelay, "Execution should be delayed by at least minDelay (" + minDelay + "ms) when min > max. maxDelay (" + maxDelay + ")"); + assertTrue(duration < (minDelay + 30), "Execution should treat minDelay as the fixed delay when min > max"); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptorTest.java new file mode 100644 index 0000000..0e18e4c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptorTest.java @@ -0,0 +1,96 @@ +package me.cxdev.commerce.proxy.interceptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; + +import io.undertow.io.Sender; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.ws.rs.core.MediaType; + +@ExtendWith(MockitoExtension.class) +class StaticResponseInterceptorTest { + @Mock + private HttpServerExchange exchangeMock; + + @Mock + private Sender senderMock; + + private HeaderMap responseHeaders; + + @BeforeEach + void setUp() { + responseHeaders = new HeaderMap(); + + Mockito.lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + Mockito.lenient().when(exchangeMock.getResponseSender()).thenReturn(senderMock); + } + + @Test + void testApply_SetsStatusCodeContentTypeAndPayload() throws Exception { + int statusCode = 200; + String contentType = "application/json"; + String payload = "{\"status\": \"mocked\", \"data\": []}"; + + ProxyExchangeInterceptor interceptor = Interceptors.staticResponse(statusCode, contentType, payload); + + interceptor.apply(exchangeMock); + verify(exchangeMock).setStatusCode(statusCode); + assertEquals(contentType, responseHeaders.getFirst(Headers.CONTENT_TYPE), + "Content-Type header should match the configured value"); + verify(senderMock).send(payload); + } + + @Test + void testApply_WithNullPayload_HandlesGracefully() throws Exception { + ProxyExchangeInterceptor interceptor = Interceptors.staticResponse(404, "text/plain", null); + + interceptor.apply(exchangeMock); + + verify(exchangeMock).setStatusCode(404); + assertEquals("text/plain", responseHeaders.getFirst(Headers.CONTENT_TYPE)); + verify(senderMock).send(""); + } + + @Test + void testApply_WithEmptyContentType_IgnoresHeader() throws Exception { + ProxyExchangeInterceptor interceptor = Interceptors.staticResponse(204, "", ""); + + interceptor.apply(exchangeMock); + + verify(exchangeMock).setStatusCode(204); + assertEquals("text/plain", responseHeaders.getFirst(Headers.CONTENT_TYPE)); + verify(senderMock).send(""); + } + + @Test + void testApply_WithJsonResponse_HandlesGracefully() throws Exception { + ProxyExchangeInterceptor interceptor = Interceptors.jsonResponse("{}"); + + interceptor.apply(exchangeMock); + + verify(exchangeMock).setStatusCode(200); + assertEquals(MediaType.APPLICATION_JSON, responseHeaders.getFirst(Headers.CONTENT_TYPE)); + verify(senderMock).send("{}"); + } + + @Test + void testApply_WithHtmlResponse_HandlesGracefully() throws Exception { + ProxyExchangeInterceptor interceptor = Interceptors.htmlResponse("TEST"); + + interceptor.apply(exchangeMock); + + verify(exchangeMock).setStatusCode(200); + assertEquals(MediaType.TEXT_HTML, responseHeaders.getFirst(Headers.CONTENT_TYPE)); + verify(senderMock).send("TEST"); + } +} From 6344c44e819c6c25e3dcc94cf819b31003622a15 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 16:15:38 +0100 Subject: [PATCH 11/25] add tests for util package --- .../i18n/ClasspathMergingMessageSource.java | 6 +- .../interceptor/NetworkDelayInterceptor.java | 7 +- .../proxy/livecycle/UndertowProxyManager.java | 6 +- .../cxdev/commerce/proxy/util/TimeUtils.java | 52 ++++++------ .../proxy/util/ResourcePathUtilsTest.java | 82 +++++++++++++++++++ .../commerce/proxy/util/TimeUtilsTest.java | 59 +++++++++++++ 6 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/ResourcePathUtilsTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/TimeUtilsTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java index 3097f21..06ffc3a 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java @@ -42,7 +42,11 @@ public void setBaseName(String baseName) { * @param interval The interval string from Spring properties. */ public void setCacheRefreshIntervalMillis(String interval) { - this.cacheRefreshIntervalMillis = TimeUtils.parseIntervalToMillis(interval, 5000L, "Message cache refresh interval"); + try { + this.cacheRefreshIntervalMillis = TimeUtils.parseIntervalToMillis(interval, "Message cache refresh interval"); + } catch (NumberFormatException e) { + LOG.warn("Invalid refresh interval {} for message source, using current value '{}'.", interval, this.cacheRefreshIntervalMillis); + } } @Override diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java index ca1f8ec..c514b91 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java @@ -19,7 +19,6 @@ */ class NetworkDelayInterceptor implements ProxyExchangeInterceptor { private static final Logger LOG = LoggerFactory.getLogger(NetworkDelayInterceptor.class); - private static final long DEFAULT_DELAY_INMILLIS = 1000L; private long minDelayInMillis; private long maxDelayInMillis; @@ -28,14 +27,14 @@ class NetworkDelayInterceptor implements ProxyExchangeInterceptor { * Convenience constructor to assign a fixed delay (sets both min and max to the same value). */ NetworkDelayInterceptor(String delay) { - this.minDelayInMillis = TimeUtils.parseIntervalToMillis(delay, DEFAULT_DELAY_INMILLIS, "Network delay interceptor interval"); + this.minDelayInMillis = TimeUtils.parseIntervalToMillis(delay, "Network delay interceptor interval"); this.maxDelayInMillis = this.minDelayInMillis; } NetworkDelayInterceptor(String minDelay, String maxDelay) { - this.minDelayInMillis = TimeUtils.parseIntervalToMillis(minDelay, DEFAULT_DELAY_INMILLIS, "Network minimum delay interceptor interval"); + this.minDelayInMillis = TimeUtils.parseIntervalToMillis(minDelay, "Network minimum delay interceptor interval"); ; - this.maxDelayInMillis = TimeUtils.parseIntervalToMillis(maxDelay, DEFAULT_DELAY_INMILLIS, "Network maximum delay interceptor interval"); + this.maxDelayInMillis = TimeUtils.parseIntervalToMillis(maxDelay, "Network maximum delay interceptor interval"); ; } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java index 0c05460..f754661 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java @@ -530,7 +530,11 @@ public void setBackendContexts(String backendContexts) { * @param interval The interval string from Spring properties. */ public void setGroovyRuleReloadInterval(String interval) { - this.groovyRuleReloadIntervalMs = TimeUtils.parseIntervalToMillis(interval, 5000L, "Groovy rule reload interval"); + try { + this.groovyRuleReloadIntervalMs = TimeUtils.parseIntervalToMillis(interval, "Groovy rule reload interval"); + } catch (NumberFormatException e) { + LOG.warn("Invalid refresh interval {} for rule reloading, using current value '{}'.", interval, this.groovyRuleReloadIntervalMs); + } } public void setGroovyRuleEngineService(GroovyRuleEngineService groovyRuleEngineService) { diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java index 92b61be..d997778 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java @@ -14,48 +14,46 @@ private TimeUtils() { // Prevent instantiation } + public static long parseIntervalToMillis(String interval) { + return parseIntervalToMillis(interval, "TimeUtils parser."); + } + /** * Parses a time interval string into milliseconds. * Supports 'ms', 's', 'm', 'h', and 'd'. Falls back to milliseconds if no unit is provided. * * @param interval The interval string from properties (e.g., "5s"). - * @param defaultValue The fallback value in milliseconds if parsing fails or input is empty. * @param contextName A descriptive name for logging (e.g., "Groovy rule reload"). * @return The parsed interval in milliseconds. */ - public static long parseIntervalToMillis(String interval, long defaultValue, String contextName) { + public static long parseIntervalToMillis(String interval, String contextName) { if (interval == null || interval.trim().isEmpty()) { - return defaultValue; + return 0L; } String trimmed = interval.trim().toLowerCase(); long multiplier = 1; long value; - try { - if (trimmed.endsWith("ms")) { - value = Long.parseLong(trimmed.substring(0, trimmed.length() - 2)); - } else if (trimmed.endsWith("s")) { - value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); - multiplier = 1000L; - } else if (trimmed.endsWith("m")) { - value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); - multiplier = 60L * 1000L; - } else if (trimmed.endsWith("h")) { - value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); - multiplier = 60L * 60L * 1000L; - } else if (trimmed.endsWith("d")) { - value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); - multiplier = 24L * 60L * 60L * 1000L; - } else { - value = Long.parseLong(trimmed); // Default to ms if no unit - } - long result = value * multiplier; - LOG.debug("Parsed time interval for '{}' to {} ms", contextName, result); - return result; - } catch (NumberFormatException e) { - LOG.error("Invalid time format for {} ('{}'). Using default: {} ms", contextName, interval, defaultValue); - return defaultValue; + if (trimmed.endsWith("ms")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 2)); + } else if (trimmed.endsWith("s")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 1000L; + } else if (trimmed.endsWith("m")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 60L * 1000L; + } else if (trimmed.endsWith("h")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 60L * 60L * 1000L; + } else if (trimmed.endsWith("d")) { + value = Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + multiplier = 24L * 60L * 60L * 1000L; + } else { + value = Long.parseLong(trimmed); // Default to ms if no unit } + long result = value * multiplier; + LOG.debug("Parsed time interval for '{}' to {} ms", contextName, result); + return result; } } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/ResourcePathUtilsTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/ResourcePathUtilsTest.java new file mode 100644 index 0000000..871af36 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/ResourcePathUtilsTest.java @@ -0,0 +1,82 @@ +package me.cxdev.commerce.proxy.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class ResourcePathUtilsTest { + private static final String CONTEXT = "test context"; + + // --- File Path Normalization Tests --- + + @Test + void testNormalizeFilePath_WithValidPrefixes_ReturnsAsIs() { + assertEquals("classpath:my/script.groovy", + ResourcePathUtils.normalizeFilePath("classpath:my/script.groovy", CONTEXT)); + assertEquals("file:/opt/my/script.groovy", + ResourcePathUtils.normalizeFilePath("file:/opt/my/script.groovy", CONTEXT)); + } + + @Test + void testNormalizeFilePath_WithClasspathStar_ReplacesPrefix() { + assertEquals("classpath:my/script.groovy", + ResourcePathUtils.normalizeFilePath("classpath*:my/script.groovy", CONTEXT), + "Should automatically fix the invalid 'classpath*:' prefix"); + } + + @Test + void testNormalizeFilePath_WithoutPrefix_PrependsClasspath() { + assertEquals("classpath:my/script.groovy", + ResourcePathUtils.normalizeFilePath("my/script.groovy", CONTEXT), + "Should fallback to 'classpath:' if no protocol is provided"); + } + + @Test + void testNormalizeFilePath_WithWhitespace_TrimsPath() { + assertEquals("classpath:my/script.groovy", + ResourcePathUtils.normalizeFilePath(" classpath:my/script.groovy ", CONTEXT)); + } + + @Test + void testNormalizeFilePath_NullOrEmpty_ReturnsAsIs() { + assertNull(ResourcePathUtils.normalizeFilePath(null, CONTEXT)); + assertEquals("", ResourcePathUtils.normalizeFilePath("", CONTEXT)); + assertEquals(" ", ResourcePathUtils.normalizeFilePath(" ", CONTEXT), + "Blank strings should be returned as is according to the implementation"); + } + + // --- Directory Path Normalization Tests --- + + @Test + void testNormalizeDirectoryPath_RemovesTrailingSlash() { + assertEquals("classpath:my/dir", + ResourcePathUtils.normalizeDirectoryPath("classpath:my/dir/", CONTEXT)); + assertEquals("file:/opt/my/dir", + ResourcePathUtils.normalizeDirectoryPath("file:/opt/my/dir/", CONTEXT)); + } + + @Test + void testNormalizeDirectoryPath_WithoutTrailingSlash_RemainsUnchanged() { + assertEquals("classpath:my/dir", + ResourcePathUtils.normalizeDirectoryPath("classpath:my/dir", CONTEXT)); + } + + @Test + void testNormalizeDirectoryPath_AppliesPrefixFixes() { + // Testet die Kombination aus Trailing-Slash-Removal und Prefix-Fix + assertEquals("classpath:my/dir", + ResourcePathUtils.normalizeDirectoryPath("classpath*:my/dir/", CONTEXT)); + assertEquals("classpath:my/dir", + ResourcePathUtils.normalizeDirectoryPath("my/dir/", CONTEXT)); + assertEquals("classpath:my/dir", + ResourcePathUtils.normalizeDirectoryPath(" my/dir/ ", CONTEXT)); + } + + @Test + void testNormalizeDirectoryPath_NullOrEmpty_ReturnsAsIs() { + assertNull(ResourcePathUtils.normalizeDirectoryPath(null, CONTEXT)); + assertEquals("", ResourcePathUtils.normalizeDirectoryPath("", CONTEXT)); + assertEquals(" ", ResourcePathUtils.normalizeDirectoryPath(" ", CONTEXT)); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/TimeUtilsTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/TimeUtilsTest.java new file mode 100644 index 0000000..a5a4ba5 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/util/TimeUtilsTest.java @@ -0,0 +1,59 @@ +package me.cxdev.commerce.proxy.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class TimeUtilsTest { + @Test + void testParseDuration_WithSeconds() { + assertEquals(5000L, TimeUtils.parseIntervalToMillis("5s")); + assertEquals(3600000L, TimeUtils.parseIntervalToMillis("3600s")); + } + + @Test + void testParseDuration_WithMinutes() { + assertEquals(60000L, TimeUtils.parseIntervalToMillis("1m")); + assertEquals(3600000L, TimeUtils.parseIntervalToMillis("60m")); + } + + @Test + void testParseDuration_WithHours() { + assertEquals(3600000L, TimeUtils.parseIntervalToMillis("1h")); + assertEquals(36000000L, TimeUtils.parseIntervalToMillis("10h")); + } + + @Test + void testParseDuration_WithDays() { + assertEquals(86400000L, TimeUtils.parseIntervalToMillis("1d")); + } + + @Test + void testParseDuration_WithMilliseconds() { + assertEquals(500L, TimeUtils.parseIntervalToMillis("500ms")); + } + + @Test + void testParseDuration_WithRawNumber_DefaultsToMillis() { + assertEquals(800L, TimeUtils.parseIntervalToMillis("800")); + } + + @Test + void testParseDuration_WithNullOrEmpty_ReturnsZero() { + assertEquals(0L, TimeUtils.parseIntervalToMillis(null)); + assertEquals(0L, TimeUtils.parseIntervalToMillis("")); + assertEquals(0L, TimeUtils.parseIntervalToMillis(" ")); + } + + @Test + void testParseDuration_WithInvalidFormat_ThrowsException() { + assertThrows(NumberFormatException.class, () -> { + TimeUtils.parseIntervalToMillis("10x"); + }, "Should throw an exception for unknown time units"); + + assertThrows(NumberFormatException.class, () -> { + TimeUtils.parseIntervalToMillis("abc"); + }, "Should throw an exception for completely invalid formats"); + } +} From 29313bcf186adb421a9858525a044720bacb27eb Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 16:22:28 +0100 Subject: [PATCH 12/25] add tests for SSL handling --- .../proxy/livecycle/UndertowProxyManager.java | 2 +- .../{trust => ssl}/AcceptAllTrustManager.java | 2 +- .../proxy/ssl/AcceptAllTrustManagerTest.java | 107 ++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) rename core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/{trust => ssl}/AcceptAllTrustManager.java (95%) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManagerTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java index f754661..2ac390e 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java @@ -45,7 +45,7 @@ import me.cxdev.commerce.proxy.handler.ProxyRouteHandler; import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptor; -import me.cxdev.commerce.proxy.trust.AcceptAllTrustManager; +import me.cxdev.commerce.proxy.ssl.AcceptAllTrustManager; import me.cxdev.commerce.proxy.util.ResourcePathUtils; import me.cxdev.commerce.proxy.util.TimeUtils; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/trust/AcceptAllTrustManager.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManager.java similarity index 95% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/trust/AcceptAllTrustManager.java rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManager.java index f3b3fb5..e59141c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/trust/AcceptAllTrustManager.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManager.java @@ -1,4 +1,4 @@ -package me.cxdev.commerce.proxy.trust; +package me.cxdev.commerce.proxy.ssl; import java.net.Socket; import java.security.cert.X509Certificate; diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManagerTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManagerTest.java new file mode 100644 index 0000000..cc5fcb1 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/ssl/AcceptAllTrustManagerTest.java @@ -0,0 +1,107 @@ +package me.cxdev.commerce.proxy.ssl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.net.Socket; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLEngine; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AcceptAllTrustManagerTest { + private AcceptAllTrustManager trustManager; + + @Mock + private X509Certificate mockCertificate; + + @Mock + private Socket mockSocket; + + @Mock + private SSLEngine mockEngine; + + @BeforeEach + void setUp() { + trustManager = new AcceptAllTrustManager(); + } + + // --- Standard X509TrustManager Methods --- + + @Test + void testCheckClientTrusted_NeverThrowsException() { + X509Certificate[] validChain = new X509Certificate[] { mockCertificate }; + + assertDoesNotThrow(() -> trustManager.checkClientTrusted(validChain, "RSA")); + assertDoesNotThrow(() -> trustManager.checkClientTrusted(null, null)); + } + + @Test + void testCheckServerTrusted_NeverThrowsException() { + X509Certificate[] validChain = new X509Certificate[] { mockCertificate }; + + assertDoesNotThrow(() -> trustManager.checkServerTrusted(validChain, "RSA")); + assertDoesNotThrow(() -> trustManager.checkServerTrusted(null, null)); + } + + // --- Extended X509ExtendedTrustManager Methods (Socket) --- + + @Test + void testCheckClientTrustedWithSocket_NeverThrowsException() { + X509Certificate[] validChain = new X509Certificate[] { mockCertificate }; + + assertDoesNotThrow(() -> trustManager.checkClientTrusted(validChain, "RSA", mockSocket), + "Should silently accept valid chains with a Socket"); + assertDoesNotThrow(() -> trustManager.checkClientTrusted(null, null, (Socket) null), + "Should silently accept null values with a null Socket"); + } + + @Test + void testCheckServerTrustedWithSocket_NeverThrowsException() { + X509Certificate[] validChain = new X509Certificate[] { mockCertificate }; + + assertDoesNotThrow(() -> trustManager.checkServerTrusted(validChain, "RSA", mockSocket), + "Should silently accept valid chains with a Socket"); + assertDoesNotThrow(() -> trustManager.checkServerTrusted(null, null, (Socket) null), + "Should silently accept null values with a null Socket"); + } + + // --- Extended X509ExtendedTrustManager Methods (SSLEngine) --- + + @Test + void testCheckClientTrustedWithEngine_NeverThrowsException() { + X509Certificate[] validChain = new X509Certificate[] { mockCertificate }; + + assertDoesNotThrow(() -> trustManager.checkClientTrusted(validChain, "RSA", mockEngine), + "Should silently accept valid chains with an SSLEngine"); + assertDoesNotThrow(() -> trustManager.checkClientTrusted(null, null, (SSLEngine) null), + "Should silently accept null values with a null SSLEngine"); + } + + @Test + void testCheckServerTrustedWithEngine_NeverThrowsException() { + X509Certificate[] validChain = new X509Certificate[] { mockCertificate }; + + assertDoesNotThrow(() -> trustManager.checkServerTrusted(validChain, "RSA", mockEngine), + "Should silently accept valid chains with an SSLEngine"); + assertDoesNotThrow(() -> trustManager.checkServerTrusted(null, null, (SSLEngine) null), + "Should silently accept null values with a null SSLEngine"); + } + + // --- Array Return Method --- + + @Test + void testGetAcceptedIssuers_ReturnsEmptyArray() { + X509Certificate[] issuers = trustManager.getAcceptedIssuers(); + + assertNotNull(issuers, "Accepted issuers should not be null to prevent NullPointerExceptions"); + assertEquals(0, issuers.length, "Accepted issuers array should be empty"); + } +} From 9ca7b03c8e01c3390161929a0119f13a5b54bb20 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 17:07:46 +0100 Subject: [PATCH 13/25] add tests for message source --- .../proxy/constants/CxDevProxyConstants.java | 2 - .../ClasspathMergingMessageSourceTest.java | 169 ++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSourceTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/constants/CxDevProxyConstants.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/constants/CxDevProxyConstants.java index a8fce5b..7935533 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/constants/CxDevProxyConstants.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/constants/CxDevProxyConstants.java @@ -9,6 +9,4 @@ public final class CxDevProxyConstants extends GeneratedCxDevProxyConstants { private CxDevProxyConstants() { // empty to avoid instantiating this constant class } - - // implement here constants used by this extension } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSourceTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSourceTest.java new file mode 100644 index 0000000..94aa732 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSourceTest.java @@ -0,0 +1,169 @@ +package me.cxdev.commerce.proxy.i18n; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.Locale; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +@ExtendWith(MockitoExtension.class) +class ClasspathMergingMessageSourceTest { + private ClasspathMergingMessageSource messageSource; + + @BeforeEach + void setUp() { + messageSource = new ClasspathMergingMessageSource(); + messageSource.setBaseName("cxdevproxy/i18n/messages"); + messageSource.setCacheRefreshIntervalMillis("0s"); + } + + private Resource createMockResource(String propertiesContent, File mockFile) throws IOException { + Resource resource = mock(Resource.class); + lenient().when(resource.getInputStream()).thenAnswer(inv -> new ByteArrayInputStream(propertiesContent.getBytes(StandardCharsets.ISO_8859_1))); + + if (mockFile != null) { + lenient().when(resource.getFile()).thenReturn(mockFile); + } else { + lenient().when(resource.getFile()).thenThrow(new IOException("Not a file system resource")); + } + return resource; + } + + @Test + void testMergingMultipleResources() throws Exception { + File file1 = mock(File.class); + Resource res1 = createMockResource("key.one=value1\nkey.shared=from1", file1); + + File file2 = mock(File.class); + Resource res2 = createMockResource("key.two=value2\nkey.shared=from2", file2); + + try (MockedConstruction mocked = Mockito.mockConstruction( + PathMatchingResourcePatternResolver.class, + (mockResolver, context) -> { + when(mockResolver.getResources(anyString())).thenReturn(new Resource[] { res1, res2 }); + })) { + + assertEquals("value1", messageSource.getMessage("key.one", null, "default", Locale.ENGLISH)); + assertEquals("value2", messageSource.getMessage("key.two", null, "default", Locale.ENGLISH)); + + assertEquals("from2", messageSource.getMessage("key.shared", null, "default", Locale.ENGLISH)); + } + } + + @Test + void testHotReloadingOnModifiedFile() throws Exception { + File mockFile = mock(File.class); + when(mockFile.lastModified()).thenReturn(1000L); // Initiale "Zeit" + + String[] fileContent = new String[] { "dynamic.key=initialValue" }; + Resource res = mock(Resource.class); + when(res.getInputStream()).thenAnswer(inv -> new ByteArrayInputStream(fileContent[0].getBytes(StandardCharsets.ISO_8859_1))); + when(res.getFile()).thenReturn(mockFile); + + try (MockedConstruction mocked = Mockito.mockConstruction( + PathMatchingResourcePatternResolver.class, + (mockResolver, context) -> { + when(mockResolver.getResources(anyString())).thenReturn(new Resource[] { res }); + })) { + + assertEquals("initialValue", messageSource.getMessage("dynamic.key", null, "default", Locale.ENGLISH)); + + fileContent[0] = "dynamic.key=updatedValue"; + when(mockFile.lastModified()).thenReturn(2000L); + + assertEquals("updatedValue", messageSource.getMessage("dynamic.key", null, "default", Locale.ENGLISH), + "MessageSource should have detected the file change and reloaded properties"); + } + } + + @Test + void testCacheDebouncingInterval() throws Exception { + messageSource.setCacheRefreshIntervalMillis("50ms"); + + File mockFile = mock(File.class); + when(mockFile.lastModified()).thenReturn(1000L); + String[] fileContent = new String[] { "debounce.key=oldValue" }; + + Resource res = createMockResource(fileContent[0], mockFile); + when(res.getInputStream()).thenAnswer(inv -> new ByteArrayInputStream(fileContent[0].getBytes(StandardCharsets.ISO_8859_1))); + + try (MockedConstruction mocked = Mockito.mockConstruction( + PathMatchingResourcePatternResolver.class, + (mockResolver, context) -> { + when(mockResolver.getResources(anyString())).thenReturn(new Resource[] { res }); + })) { + + assertEquals("oldValue", messageSource.getMessage("debounce.key", null, "default", Locale.ENGLISH)); + + fileContent[0] = "debounce.key=newValue"; + when(mockFile.lastModified()).thenReturn(2000L); + + assertEquals("oldValue", messageSource.getMessage("debounce.key", null, "default", Locale.ENGLISH)); + + Thread.sleep(60); + + assertEquals("newValue", messageSource.getMessage("debounce.key", null, "default", Locale.ENGLISH)); + } + } + + @Test + void testResourceInsideJar_DoesNotCrash() throws Exception { + Resource res = createMockResource("jar.key=jarValue", null); + + try (MockedConstruction mocked = Mockito.mockConstruction( + PathMatchingResourcePatternResolver.class, + (mockResolver, context) -> { + when(mockResolver.getResources(anyString())).thenReturn(new Resource[] { res }); + })) { + + assertEquals("jarValue", messageSource.getMessage("jar.key", null, "default", Locale.ENGLISH)); + } + } + + @Test + void testInvalidRefreshInterval_FallsBackGracefully() { + messageSource.setCacheRefreshIntervalMillis("100ms"); + messageSource.setCacheRefreshIntervalMillis("invalid_format"); + } + + @Test + void testResolveCode_ReturnsMessageFormatWithPlaceholders() throws Exception { + Resource res = createMockResource("greeting.param=Hello, {0}! Welcome to {1}.", null); + + try (MockedConstruction mocked = Mockito.mockConstruction( + PathMatchingResourcePatternResolver.class, + (mockResolver, context) -> { + when(mockResolver.getResources(anyString())).thenReturn(new Resource[] { res }); + })) { + + MessageFormat format = messageSource.resolveCode("greeting.param", Locale.ENGLISH); + + assertNotNull(format, "MessageFormat should not be null for existing key"); + assertEquals("Hello, {0}! Welcome to {1}.", format.toPattern(), "The pattern should match the property value"); + assertEquals(Locale.ENGLISH, format.getLocale(), "The locale of the MessageFormat should match the requested locale"); + + String formattedMessage = format.format(new Object[] { "John", "CX Dev Proxy" }); + assertEquals("Hello, John! Welcome to CX Dev Proxy.", formattedMessage); + + MessageFormat nullFormat = messageSource.resolveCode("unknown.key", Locale.ENGLISH); + + assertNull(nullFormat, "MessageFormat should be null for missing keys"); + } + } +} From 88cf673b25f1e6ba1acc0d8f621ef6fff4fc9185 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 17:08:00 +0100 Subject: [PATCH 14/25] add tests for token service --- .../jwt/service/CxJwtTokenService.java | 35 +--- .../jwt/service/CxJwtTokenServiceTest.java | 173 ++++++++++++++++++ 2 files changed, 178 insertions(+), 30 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/jwt/service/CxJwtTokenServiceTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java index d960d03..044b7f4 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java @@ -23,7 +23,6 @@ import com.nimbusds.jwt.SignedJWT; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -31,6 +30,9 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; +import me.cxdev.commerce.proxy.util.ResourcePathUtils; +import me.cxdev.commerce.proxy.util.TimeUtils; + /** * Service responsible for loading JWT templates, generating signed tokens, and caching them. *

@@ -124,38 +126,11 @@ public void setResourceLoader(ResourceLoader resourceLoader) { } public void setTemplatePathPrefix(String templatePathPrefix) { - if (StringUtils.isNotBlank(templatePathPrefix)) { - this.templatePathPrefix = templatePathPrefix; - } + this.templatePathPrefix = ResourcePathUtils.normalizeDirectoryPath(templatePathPrefix, "Template path in JwtTokenService"); } - /** - * Smart setter that parses strings like "1d", "10h", "60m", "3600s", "3600000ms" into milliseconds. - * Falls back to raw milliseconds if no unit is provided. - */ public void setTokenValidity(String validity) { - if (StringUtils.isBlank(validity)) { - return; - } - String val = validity.trim().toLowerCase(); - try { - if (val.endsWith("ms")) { - this.tokenValidityMs = Long.parseLong(val.replace("ms", "")); - } else if (val.endsWith("s")) { - this.tokenValidityMs = Long.parseLong(val.replace("s", "")) * 1000L; - } else if (val.endsWith("m")) { - this.tokenValidityMs = Long.parseLong(val.replace("m", "")) * 60 * 1000L; - } else if (val.endsWith("h")) { - this.tokenValidityMs = Long.parseLong(val.replace("h", "")) * 60 * 60 * 1000L; - } else if (val.endsWith("d")) { - this.tokenValidityMs = Long.parseLong(val.replace("d", "")) * 24 * 60 * 60 * 1000L; - } else { - this.tokenValidityMs = Long.parseLong(val); // default to ms - } - LOG.info("Configured JWT token validity to {} ms", this.tokenValidityMs); - } catch (NumberFormatException e) { - LOG.error("Invalid token validity format '{}'. Using default of 1 hour.", validity); - } + this.tokenValidityMs = TimeUtils.parseIntervalToMillis(validity, "Token validity for JwtTokenService"); } public void setJwkSource(JWKSource jwkSource) { diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/jwt/service/CxJwtTokenServiceTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/jwt/service/CxJwtTokenServiceTest.java new file mode 100644 index 0000000..21c36f2 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/jwt/service/CxJwtTokenServiceTest.java @@ -0,0 +1,173 @@ +package me.cxdev.commerce.jwt.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Collections; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.SignedJWT; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +@ExtendWith(MockitoExtension.class) +class CxJwtTokenServiceTest { + private CxJwtTokenService tokenService; + + @Mock + private ResourceLoader resourceLoaderMock; + + @Mock + private JWKSource jwkSourceMock; + + @Mock + private Resource resourceMock; + + private RSAKey testRsaJwk; + + @BeforeEach + void setUp() throws Exception { + tokenService = new CxJwtTokenService(); + tokenService.setResourceLoader(resourceLoaderMock); + tokenService.setTemplatePathPrefix("cxdevproxy/jwt"); + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + testRsaJwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .keyID("test-key-123") + .build(); + } + + // --- JWKSource & Initialization Tests --- + + @Test + void testAfterPropertiesSet_LoadsPrivateKeySuccessfully() throws Exception { + tokenService.setJwkSource(jwkSourceMock); + when(jwkSourceMock.get(any(), any())).thenReturn(Collections.singletonList((JWK) testRsaJwk)); + + tokenService.afterPropertiesSet(); + + lenient().when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + lenient().when(resourceMock.exists()).thenReturn(false); + + assertNull(tokenService.getOrGenerateToken("customer", "john.doe@example.com")); + verify(resourceLoaderMock).getResource(anyString()); + } + + @Test + void testGetOrGenerateToken_WithoutPrivateKey_ReturnsNullImmediately() { + String token = tokenService.getOrGenerateToken("customer", "jane.doe@example.com"); + + assertNull(token, "Should return null if no private key is loaded"); + verify(resourceLoaderMock, times(0)).getResource(anyString()); + } + + // --- Token Generation & Caching Tests --- + + @Test + void testGenerateSignedToken_WithValidTemplate_ReturnsValidJwt() throws Exception { + tokenService.setJwkSource(jwkSourceMock); + when(jwkSourceMock.get(any(), any())).thenReturn(Collections.singletonList((JWK) testRsaJwk)); + tokenService.afterPropertiesSet(); + + String jsonTemplate = "{\"sub\": \"john.doe@example.com\", \"roles\": [\"b2bcustomergroup\"]}"; + when(resourceLoaderMock.getResource("classpath:cxdevproxy/jwt/customer/john.doe@example.com.json")).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getInputStream()).thenReturn(new ByteArrayInputStream(jsonTemplate.getBytes(StandardCharsets.UTF_8))); + + String jwtString = tokenService.generateSignedToken("customer", "john.doe@example.com"); + + assertNotNull(jwtString, "Generated token should not be null"); + + SignedJWT parsedJwt = SignedJWT.parse(jwtString); + assertEquals("test-key-123", parsedJwt.getHeader().getKeyID(), "Key ID must match"); + assertEquals("john.doe@example.com", parsedJwt.getJWTClaimsSet().getSubject(), "Subject must match template"); + assertEquals("cxdevproxy", parsedJwt.getJWTClaimsSet().getIssuer(), "Issuer must be set by service"); + } + + @Test + void testGetOrGenerateToken_CachesTokenCorrectly() throws Exception { + tokenService.setJwkSource(jwkSourceMock); + when(jwkSourceMock.get(any(), any())).thenReturn(Collections.singletonList((JWK) testRsaJwk)); + tokenService.afterPropertiesSet(); + + String jsonTemplate = "{\"sub\": \"cached.user@example.com\"}"; + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getInputStream()).thenReturn(new ByteArrayInputStream(jsonTemplate.getBytes(StandardCharsets.UTF_8))); + + String firstCallToken = tokenService.getOrGenerateToken("customer", "cached.user@example.com"); + assertNotNull(firstCallToken); + verify(resourceLoaderMock, times(1)).getResource(anyString()); // Template wurde geladen + + String secondCallToken = tokenService.getOrGenerateToken("customer", "cached.user@example.com"); + assertEquals(firstCallToken, secondCallToken, "Must return the exact same token from cache"); + verify(resourceLoaderMock, times(1)).getResource(anyString()); // Template wurde NICHT noch einmal geladen + } + + @Test + void testGetOrGenerateToken_WithExpiredCache_GeneratesNewToken() throws Exception { + // Trick: We manipulate the token validity to 10 seconds. + // This makes expiry = now + 10s - 60s (safety buffer) = now - 50s. + // Thus, the token is already "expired" the moment it enters the cache. + tokenService.setTokenValidity("1m"); + + // Setup: Initialize Key & JWKSource + tokenService.setJwkSource(jwkSourceMock); + when(jwkSourceMock.get(any(), any())).thenReturn(Collections.singletonList((JWK) testRsaJwk)); + tokenService.afterPropertiesSet(); + + // Setup: Mock Template + String jsonTemplate = "{\"sub\": \"expired.user@example.com\"}"; + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + + // Since the resource is read twice, we must provide a fresh InputStream each time + when(resourceMock.getInputStream()).thenAnswer(inv -> new ByteArrayInputStream(jsonTemplate.getBytes(StandardCharsets.UTF_8))); + + // 1st call: Generates the token and puts it (already expired) into the cache + String firstCallToken = tokenService.getOrGenerateToken("customer", "expired.user@example.com"); + assertNotNull(firstCallToken); + + // Verify that the template was loaded exactly once + verify(resourceLoaderMock, times(1)).getResource(anyString()); + + // Short pause (10ms) to ensure the "issueTime" timestamp of the new JWT is strictly different + Thread.sleep(50); + + // 2nd call: Finds the token in cache, detects it is invalid, and regenerates it + String secondCallToken = tokenService.getOrGenerateToken("customer", "expired.user@example.com"); + assertNotNull(secondCallToken); + + // Assert 1: The template must have been loaded a SECOND time + verify(resourceLoaderMock, times(2)).getResource(anyString()); + + // Assert 2: The two token strings must differ because a completely new signature + // with a new timestamp was generated. + assertNotSame(firstCallToken, secondCallToken, + "Expired token should be discarded and a completely new one generated"); + } +} From 98c1d3074c6ad3fe9d423e0a4032738cd618b307 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 17:11:56 +0100 Subject: [PATCH 15/25] add tests for rule engine --- .../GroovyRuleEngineServiceTest.java | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineServiceTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineServiceTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineServiceTest.java new file mode 100644 index 0000000..7fb0c8c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineServiceTest.java @@ -0,0 +1,166 @@ +package me.cxdev.commerce.proxy.livecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptor; +import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptorCondition; + +@ExtendWith(MockitoExtension.class) +class GroovyRuleEngineServiceTest { + private GroovyRuleEngineService engineService; + + @Mock + private ApplicationContext applicationContextMock; + + @Mock + private ResourceLoader resourceLoaderMock; + + @Mock + private Resource resourceMock; + + @Mock + private ProxyExchangeInterceptor mockInterceptor; + + @Mock + private ProxyExchangeInterceptorCondition mockCondition; + + @BeforeEach + void setUp() { + engineService = new GroovyRuleEngineService(); + engineService.setResourceLoader(resourceLoaderMock); + + // Setup Spring ApplicationContext mocks with custom bean names to test the prefix stripping + Map interceptors = new HashMap<>(); + interceptors.put("cxdevproxyInterceptorForwardedHeaders", mockInterceptor); + interceptors.put("customInterceptorWithoutPrefix", mockInterceptor); // Edge case + + Map conditions = new HashMap<>(); + conditions.put("cxdevproxyConditionIsOcc", mockCondition); + + when(applicationContextMock.getBeansOfType(ProxyExchangeInterceptor.class)).thenReturn(interceptors); + when(applicationContextMock.getBeansOfType(ProxyExchangeInterceptorCondition.class)).thenReturn(conditions); + + // This call triggers initGroovyShell() internally + engineService.setApplicationContext(applicationContextMock); + } + + // --- File Resolution Tests --- + + @Test + void testResolveScriptFile_WithExistingResource_ReturnsFile() throws Exception { + // Setup: Mock a valid classpath resource + File mockFile = mock(File.class); + when(resourceLoaderMock.getResource("classpath:my/script.groovy")).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getFile()).thenReturn(mockFile); + + File resolvedFile = engineService.resolveScriptFile("my/script.groovy"); + + assertNotNull(resolvedFile, "Should return a valid File object for existing resources"); + assertEquals(mockFile, resolvedFile); + } + + @Test + void testResolveScriptFile_WithMissingResource_ReturnsNull() { + // Setup: Mock a resource that does not exist + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(false); + + File resolvedFile = engineService.resolveScriptFile("missing/script.groovy"); + + assertNull(resolvedFile, "Should return null gracefully if the resource does not exist"); + } + + @Test + void testResolveScriptFile_OnException_ReturnsNullGracefully() throws Exception { + // Setup: Mock an IO exception during file retrieval (e.g., inside a JAR) + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getFile()).thenThrow(new java.io.IOException("Inside JAR")); + + File resolvedFile = engineService.resolveScriptFile("jar/script.groovy"); + + assertNull(resolvedFile, "Should swallow the exception and return null"); + } + + // --- Script Evaluation & Binding Tests --- + + @Test + void testEvaluateScript_WithValidScriptAndBindings(@TempDir Path tempDir) throws Exception { + // Setup: Create a real temporary file containing a Groovy script. + // We use the variables exactly as they should be named after prefix-stripping. + // We also test the static imports from Interceptors.class (e.g., jsonResponse). + String groovyCode = "def interceptor1 = forwardedHeaders\n" + + "def condition1 = isOcc\n" + + "def fallback = customInterceptorWithoutPrefix\n" + + "def inlineInterceptor = jsonResponse('{}')\n" + + "return [interceptor1, inlineInterceptor]\n"; + + File scriptFile = tempDir.resolve("rules.groovy").toFile(); + Files.writeString(scriptFile.toPath(), groovyCode); + + // Execution + List result = engineService.evaluateScript(scriptFile); + + // Assertions + assertNotNull(result, "Result list should not be null"); + assertEquals(2, result.size(), "Script should return exactly two interceptors"); + + // Assert that the first returned interceptor is the exact mock instance we injected, + // proving that 'forwardedHeaders' was successfully bound to 'cxdevproxyInterceptorForwardedHeaders' + assertEquals(mockInterceptor, result.get(0)); + } + + @Test + void testEvaluateScript_ReturnsEmptyListOnWrongReturnType(@TempDir Path tempDir) throws Exception { + // Setup: A script that returns a String instead of List + File scriptFile = tempDir.resolve("wrong_return.groovy").toFile(); + Files.writeString(scriptFile.toPath(), "return 'I am a string, not a list'"); + + List result = engineService.evaluateScript(scriptFile); + + assertNotNull(result); + assertTrue(result.isEmpty(), "Should gracefully return an empty list if the script returns the wrong type"); + } + + @Test + void testEvaluateScript_ReturnsEmptyListOnSyntaxError(@TempDir Path tempDir) throws Exception { + // Setup: A script with invalid Groovy syntax + File scriptFile = tempDir.resolve("syntax_error.groovy").toFile(); + Files.writeString(scriptFile.toPath(), "def invalid code structure {"); + + List result = engineService.evaluateScript(scriptFile); + + assertNotNull(result); + assertTrue(result.isEmpty(), "Should swallow compilation exceptions and return an empty list"); + } + + @Test + void testEvaluateScript_WithNullOrMissingFile_ReturnsEmptyList() { + assertTrue(engineService.evaluateScript(null).isEmpty(), "Null file should return empty list"); + assertTrue(engineService.evaluateScript(new File("does_not_exist.groovy")).isEmpty(), "Missing file should return empty list"); + } +} From 1a12171fd1a143fc9dbf7086a12ec3325421a7fa Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 17:21:43 +0100 Subject: [PATCH 16/25] add tests for startup page handler incl localizations in all languages --- .../cxdevproxy/i18n/messages_de.properties | 2 + .../cxdevproxy/i18n/messages_en.properties | 2 + .../cxdevproxy/i18n/messages_es.properties | 17 +++ .../cxdevproxy/i18n/messages_fr.properties | 17 +++ .../cxdevproxy/i18n/messages_it.properties | 17 +++ .../cxdevproxy/i18n/messages_ja.properties | 17 +++ .../cxdevproxy/i18n/messages_no.properties | 17 +++ .../cxdevproxy/i18n/messages_pt.properties | 17 +++ .../cxdevproxy/i18n/messages_ru.properties | 17 +++ .../cxdevproxy/i18n/messages_sv.properties | 17 +++ .../cxdevproxy/i18n/messages_zh.properties | 17 +++ .../cxdevproxy-locales_de.properties | 2 - .../cxdevproxy-locales_en.properties | 2 - .../cxdevproxy-locales_es.properties | 2 - .../cxdevproxy-locales_fr.properties | 2 - .../cxdevproxy-locales_it.properties | 2 - .../cxdevproxy-locales_ja.properties | 2 - .../cxdevproxy-locales_no.properties | 2 - .../cxdevproxy-locales_pt.properties | 2 - .../cxdevproxy-locales_ru.properties | 2 - .../cxdevproxy-locales_sv.properties | 2 - .../cxdevproxy-locales_zh.properties | 2 - .../proxy/handler/StartupPageHandler.java | 7 +- .../proxy/handler/StartupPageHandlerTest.java | 129 ++++++++++++++++++ 24 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_de.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_en.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_es.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_fr.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_it.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ja.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_no.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_pt.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ru.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_sv.properties delete mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_zh.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StartupPageHandlerTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties index 68c2f67..b84e8f4 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties @@ -1,3 +1,5 @@ +startup.page.title=Server startet... +startup.page.message=SAP Commerce ist am Starten. Bitte warten, diese Seite aktualisiert sich automatisch. page.title=CX Dev Proxy - Mock Login heading.main=Lokales Auth-Mocking text.description=Wähle einen Mock-Benutzer aus, um automatisch gültige JWT-Tokens in deine lokalen Storefront-Anfragen zu injizieren. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties index 43aa413..1dae34a 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties @@ -1,3 +1,5 @@ +startup.page.title=Starting up... +startup.page.message=SAP Commerce is currently starting. Please wait, this page will refresh automatically. page.title=CX Dev Proxy - Mock Login heading.main=Local Auth Mocking text.description=Select a mock user to automatically inject valid JWT tokens into your local storefront requests. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties new file mode 100644 index 0000000..c7934ac --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties @@ -0,0 +1,17 @@ +startup.page.title=Iniciando... +startup.page.message=SAP Commerce se está iniciando. Por favor espere, esta página se actualizará automáticamente. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking de Autenticación Local +text.description=Seleccione un usuario mock para inyectar automáticamente tokens JWT válidos en sus peticiones locales del storefront. +tab.b2c=Clientes B2C +tab.b2b=Clientes B2B +label.select.user=Seleccionar perfil de usuario: +user.anonymous=Visitante B2C (visitor@cxdev.me) +user.customer=Cliente B2C (customer@cxdev.me) +user.b2b.customer=Cliente B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Aprobador B2B (b2bapprover@cxdev.me) +user.b2b.manager=Gestor de Unidad B2B (b2bmanager@cxdev.me) +btn.login=Establecer Usuario Mock +btn.logout=Borrar Cookies +msg.success=Cookies mock establecidas correctamente para: +msg.cleared=Cookies mock eliminadas. Ahora está navegando como invitado estándar. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties new file mode 100644 index 0000000..054eec9 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties @@ -0,0 +1,17 @@ +startup.page.title=Démarrage en cours... +startup.page.message=SAP Commerce est en cours de démarrage. Veuillez patienter, cette page s'actualisera automatiquement. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking d'Authentification Locale +text.description=Sélectionnez un utilisateur mock pour injecter automatiquement des jetons JWT valides dans vos requêtes locales du storefront. +tab.b2c=Clients B2C +tab.b2b=Clients B2B +label.select.user=Sélectionner le profil utilisateur : +user.anonymous=Visiteur B2C (visitor@cxdev.me) +user.customer=Client B2C (customer@cxdev.me) +user.b2b.customer=Client B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Approbateur B2B (b2bapprover@cxdev.me) +user.b2b.manager=Manager d'Unité B2B (b2bmanager@cxdev.me) +btn.login=Définir l'utilisateur Mock +btn.logout=Effacer les Cookies +msg.success=Cookies mock définis avec succès pour : +msg.cleared=Cookies mock supprimés. Vous naviguez maintenant en tant qu'invité standard. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties new file mode 100644 index 0000000..3b250b2 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties @@ -0,0 +1,17 @@ +startup.page.title=Avvio in corso... +startup.page.message=SAP Commerce è in fase di avvio. Attendere, questa pagina si aggiornerà automaticamente. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking dell'Autenticazione Locale +text.description=Seleziona un utente mock per iniettare automaticamente token JWT validi nelle tue richieste locali allo storefront. +tab.b2c=Clienti B2C +tab.b2b=Clienti B2B +label.select.user=Seleziona profilo utente: +user.anonymous=Visitatore B2C (visitor@cxdev.me) +user.customer=Cliente B2C (customer@cxdev.me) +user.b2b.customer=Cliente B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Approvatore B2B (b2bapprover@cxdev.me) +user.b2b.manager=Manager di Unità B2B (b2bmanager@cxdev.me) +btn.login=Imposta Utente Mock +btn.logout=Cancella Cookie +msg.success=Cookie mock impostati con successo per: +msg.cleared=Cookie mock rimossi. Ora stai navigando come ospite standard. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties new file mode 100644 index 0000000..0af9029 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties @@ -0,0 +1,17 @@ +startup.page.title=???... +startup.page.message=SAP Commerce???????????????????????????????????????? +page.title=CX Dev Proxy - ??????? +heading.main=????????? +text.description=??????????????????????????????????JWT??????????????? +tab.b2c=B2C ?? +tab.b2b=B2B ?? +label.select.user=?????????????: +user.anonymous=B2C ??? (visitor@cxdev.me) +user.customer=B2C ?? (customer@cxdev.me) +user.b2b.customer=B2B ?? (b2bcustomer@cxdev.me) +user.b2b.approver=B2B ??? (b2bapprover@cxdev.me) +user.b2b.manager=B2B ?????????? (b2bmanager@cxdev.me) +btn.login=?????????? +btn.logout=Cookie???? +msg.success=?????Cookie???????????: +msg.cleared=???Cookie?????????????????????????????? \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties new file mode 100644 index 0000000..f97076e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties @@ -0,0 +1,17 @@ +startup.page.title=Starter opp... +startup.page.message=SAP Commerce starter for øyeblikket. Vennligst vent, denne siden vil oppdateres automatisk. +page.title=CX Dev Proxy - Mock Login +heading.main=Lokal Autentiseringsmocking +text.description=Velg en mock-bruker for å automatisk injisere gyldige JWT-tokens i dine lokale storefront-forespørsler. +tab.b2c=B2C Kunder +tab.b2b=B2B Kunder +label.select.user=Velg brukerprofil: +user.anonymous=B2C Besøkende (visitor@cxdev.me) +user.customer=B2C Kunde (customer@cxdev.me) +user.b2b.customer=B2B Kunde (b2bcustomer@cxdev.me) +user.b2b.approver=B2B Godkjenner (b2bapprover@cxdev.me) +user.b2b.manager=B2B Enhetsleder (b2bmanager@cxdev.me) +btn.login=Sett Mock-bruker +btn.logout=Tøm Informasjonskapsler +msg.success=Mock-informasjonskapsler er satt for: +msg.cleared=Mock-informasjonskapsler fjernet. Du surfer nå som en standard gjest. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties new file mode 100644 index 0000000..bf0506a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties @@ -0,0 +1,17 @@ +startup.page.title=Iniciando... +startup.page.message=O SAP Commerce está iniciando. Por favor, aguarde, esta página será atualizada automaticamente. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking de Autenticação Local +text.description=Selecione um usuário mock para injetar automaticamente tokens JWT válidos em suas requisições locais do storefront. +tab.b2c=Clientes B2C +tab.b2b=Clientes B2B +label.select.user=Selecionar Perfil de Usuário: +user.anonymous=Visitante B2C (visitor@cxdev.me) +user.customer=Cliente B2C (customer@cxdev.me) +user.b2b.customer=Cliente B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Aprovador B2B (b2bapprover@cxdev.me) +user.b2b.manager=Gerente de Unidade B2B (b2bmanager@cxdev.me) +btn.login=Definir Usuário Mock +btn.logout=Limpar Cookies +msg.success=Cookies mock definidos com sucesso para: +msg.cleared=Cookies mock removidos. Você agora está navegando como um convidado padrão. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties new file mode 100644 index 0000000..90884d5 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties @@ -0,0 +1,17 @@ +startup.page.title=??????... +startup.page.message=SAP Commerce ? ?????? ?????? ???????????. ??????????, ?????????, ??? ???????? ????????? ?????????????. +page.title=CX Dev Proxy - Mock Login +heading.main=??????????? ????????? ?????????????? +text.description=???????? mock-???????????? ??? ??????????????? ????????? ???????? JWT ??????? ? ???? ????????? ??????? ? storefront. +tab.b2c=B2C ??????? +tab.b2b=B2B ??????? +label.select.user=???????? ??????? ????????????: +user.anonymous=B2C ?????????? (visitor@cxdev.me) +user.customer=B2C ?????? (customer@cxdev.me) +user.b2b.customer=B2B ?????? (b2bcustomer@cxdev.me) +user.b2b.approver=B2B ???????????? (b2bapprover@cxdev.me) +user.b2b.manager=B2B ???????? ????????????? (b2bmanager@cxdev.me) +btn.login=?????????? Mock-???????????? +btn.logout=???????? Cookies +msg.success=Mock cookies ??????? ??????????? ???: +msg.cleared=Mock cookies ???????. ?????? ?? ?????????????? ???? ??? ??????????? ?????. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties new file mode 100644 index 0000000..ac6a64f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties @@ -0,0 +1,17 @@ +startup.page.title=Startar upp... +startup.page.message=SAP Commerce startar för närvarande. Vänligen vänta, den här sidan kommer att uppdateras automatiskt. +page.title=CX Dev Proxy - Mock Login +heading.main=Lokal Autentiseringsmocking +text.description=Välj en mock-användare för att automatiskt injicera giltiga JWT-tokens i dina lokala storefront-förfrågningar. +tab.b2c=B2C-kunder +tab.b2b=B2B-kunder +label.select.user=Välj användarprofil: +user.anonymous=B2C-besökare (visitor@cxdev.me) +user.customer=B2C-kund (customer@cxdev.me) +user.b2b.customer=B2B-kund (b2bcustomer@cxdev.me) +user.b2b.approver=B2B-godkännare (b2bapprover@cxdev.me) +user.b2b.manager=B2B-enhetschef (b2bmanager@cxdev.me) +btn.login=Ställ in Mock-användare +btn.logout=Rensa Cookies +msg.success=Mock-cookies har angetts för: +msg.cleared=Mock-cookies borttagna. Du surfar nu som en standardgäst. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties new file mode 100644 index 0000000..78437e4 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties @@ -0,0 +1,17 @@ +startup.page.title=????... +startup.page.message=SAP Commerce ???????????????????? +page.title=CX Dev Proxy - Mock ?? +heading.main=?????? Mock +text.description=???? mock ?????????? JWT ????????? storefront ???? +tab.b2c=B2C ?? +tab.b2b=B2B ?? +label.select.user=????????? +user.anonymous=B2C ?? (visitor@cxdev.me) +user.customer=B2C ?? (customer@cxdev.me) +user.b2b.customer=B2B ?? (b2bcustomer@cxdev.me) +user.b2b.approver=B2B ??? (b2bapprover@cxdev.me) +user.b2b.manager=B2B ???? (b2bmanager@cxdev.me) +btn.login=?? Mock ?? +btn.logout=?? Cookies +msg.success=?????????? Mock cookies? +msg.cleared=Mock cookies ??????????????????? \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_de.properties deleted file mode 100644 index 0d8c8f3..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_de.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Server startet... -cxdevproxy.startup.page.message=SAP Commerce ist am Starten. Bitte warten, diese Seite aktualisiert sich automatisch. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_en.properties deleted file mode 100644 index 5256c76..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_en.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Starting up... -cxdevproxy.startup.page.message=SAP Commerce is currently starting. Please wait, this page will refresh automatically. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_es.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_es.properties deleted file mode 100644 index a812999..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_es.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Iniciando... -cxdevproxy.startup.page.message=SAP Commerce se está iniciando. Por favor, espere, esta página se actualizará automáticamente. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_fr.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_fr.properties deleted file mode 100644 index 3e6619b..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_fr.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Démarrage en cours... -cxdevproxy.startup.page.message=SAP Commerce est en cours de démarrage. Veuillez patienter, cette page s'actualisera automatiquement. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_it.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_it.properties deleted file mode 100644 index 6501850..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_it.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Avvio in corso... -cxdevproxy.startup.page.message=SAP Commerce è in fase di avvio. Attendere, questa pagina si aggiornerà automaticamente. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ja.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ja.properties deleted file mode 100644 index 11be483..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ja.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=???... -cxdevproxy.startup.page.message=??SAP Commerce???????????????????????????????????? \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_no.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_no.properties deleted file mode 100644 index e8ab29c..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_no.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Starter opp... -cxdevproxy.startup.page.message=SAP Commerce starter for øyeblikket. Vennligst vent, denne siden vil oppdateres automatisk. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_pt.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_pt.properties deleted file mode 100644 index cda8852..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_pt.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Iniciando... -cxdevproxy.startup.page.message=O SAP Commerce está iniciando. Por favor, aguarde, esta página será atualizada automaticamente. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ru.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ru.properties deleted file mode 100644 index abedb31..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_ru.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=??????... -cxdevproxy.startup.page.message=SAP Commerce ? ?????? ?????? ???????????. ??????????, ?????????, ??? ???????? ????????? ?????????????. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_sv.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_sv.properties deleted file mode 100644 index ab703c0..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_sv.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=Startar... -cxdevproxy.startup.page.message=SAP Commerce startar för närvarande. Vänligen vänta, den här sidan uppdateras automatiskt. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_zh.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_zh.properties deleted file mode 100644 index cb7bacd..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/localization/cxdevproxy-locales_zh.properties +++ /dev/null @@ -1,2 +0,0 @@ -cxdevproxy.startup.page.title=????... -cxdevproxy.startup.page.message=SAP Commerce ?????????????????? \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java index 6ed44df..ef8630e 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StartupPageHandler.java @@ -27,9 +27,8 @@ *

*/ public class StartupPageHandler implements ProxyRouteHandler, TenantListener, InitializingBean { - private static final Logger LOG = LoggerFactory.getLogger(StartupPageHandler.class); - private static final String BUNDLE_BASE_NAME = "localization/cxdevproxy-locales"; + private static final String BUNDLE_BASE_NAME = "cxdevproxy/i18n/messages"; // volatile ensures thread visibility between Hybris startup threads and Undertow worker threads private volatile boolean masterTenantReady = false; @@ -80,8 +79,8 @@ public void handleRequest(HttpServerExchange exchange) { try { // Loads the message bundle natively from the classpath, bypassing the Hybris DB ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE_BASE_NAME, requestLocale); - title = bundle.getString("cxdevproxy.startup.page.title"); - message = bundle.getString("cxdevproxy.startup.page.message"); + title = bundle.getString("startup.page.title"); + message = bundle.getString("startup.page.message"); } catch (MissingResourceException e) { LOG.warn("Could not find message bundle '{}' or keys for locale '{}'. Falling back to default text.", BUNDLE_BASE_NAME, requestLocale); } diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StartupPageHandlerTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StartupPageHandlerTest.java new file mode 100644 index 0000000..4198146 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StartupPageHandlerTest.java @@ -0,0 +1,129 @@ +package me.cxdev.commerce.proxy.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import de.hybris.platform.core.Registry; +import de.hybris.platform.core.Tenant; + +import io.undertow.io.Sender; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; +import io.undertow.util.StatusCodes; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StartupPageHandlerTest { + + private StartupPageHandler handler; + + @Mock + private HttpServerExchange exchangeMock; + + @Mock + private Sender senderMock; + + @Mock + private Tenant masterTenantMock; + + @Mock + private Tenant someOtherTenantMock; + + private HeaderMap requestHeaders; + private HeaderMap responseHeaders; + + @BeforeEach + void setUp() { + handler = new StartupPageHandler(); + + requestHeaders = new HeaderMap(); + responseHeaders = new HeaderMap(); + } + + // --- Lifecycle & TenantListener Tests --- + + @Test + void testAfterPropertiesSet_RegistersListener() { + // Setup: Mock the static Registry class + try (MockedStatic registryStatic = Mockito.mockStatic(Registry.class)) { + handler.afterPropertiesSet(); + + // Assert: The handler must register itself + registryStatic.verify(() -> Registry.registerTenantListener(handler), times(1)); + } + } + + // --- Request Handling & i18n Tests --- + + @Test + void testHandleRequest_WithEnglishLocale_Returns503AndHtml() { + requestHeaders.put(Headers.ACCEPT_LANGUAGE, "en-US,en;q=0.9"); + setupExchangeMocks(); + + handler.handleRequest(exchangeMock); + + // Assert 1: HTTP Status 503 + verify(exchangeMock).setStatusCode(StatusCodes.SERVICE_UNAVAILABLE); + + // Assert 2: Content-Type is text/html + assertEquals("text/html; charset=UTF-8", responseHeaders.getFirst(Headers.CONTENT_TYPE)); + + // Assert 3: The HTML payload is sent + ArgumentCaptor htmlCaptor = ArgumentCaptor.forClass(String.class); + verify(senderMock).send(htmlCaptor.capture()); + + String html = htmlCaptor.getValue(); + assertTrue(html.contains(""), "Must be a valid HTML document"); + assertTrue(html.contains(""), "Must auto-refresh"); + + // As long as we don't strictly load a real ResourceBundle in this test classpath, + // we assert that it gracefully falls back to the hardcoded default English texts. + assertTrue(html.contains("Starting up..."), "Should contain the English default title"); + } + + @Test + void testHandleRequest_WithGermanLocale_DoesNotCrash() { + // Ensure that providing a German locale doesn't crash the ResourceBundle lookup + requestHeaders.put(Headers.ACCEPT_LANGUAGE, "de-DE,de;q=0.9"); + setupExchangeMocks(); + + handler.handleRequest(exchangeMock); + + ArgumentCaptor htmlCaptor = ArgumentCaptor.forClass(String.class); + verify(senderMock).send(htmlCaptor.capture()); + + // If the German bundle is present in the test context, it will use it. + // Otherwise, it gracefully uses the fallback. The main goal here is to ensure no exceptions escape. + assertTrue(htmlCaptor.getValue().contains("")); + } + + @Test + void testHandleRequest_WithMissingAcceptLanguage_DefaultsToEnglish() { + // No Accept-Language header set + setupExchangeMocks(); + + handler.handleRequest(exchangeMock); + + ArgumentCaptor htmlCaptor = ArgumentCaptor.forClass(String.class); + verify(senderMock).send(htmlCaptor.capture()); + + assertTrue(htmlCaptor.getValue().contains("Starting up..."), "Should gracefully default to English"); + } + + private void setupExchangeMocks() { + Mockito.lenient().when(exchangeMock.getRequestHeaders()).thenReturn(requestHeaders); + Mockito.lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + Mockito.lenient().when(exchangeMock.getResponseSender()).thenReturn(senderMock); + } +} From 000e3860326c5b0fe696006d310f47b716fdb5af Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 17:26:38 +0100 Subject: [PATCH 17/25] add tests for static content handler --- .../proxy/handler/StaticContentHandler.java | 2 + .../handler/StaticContentHandlerTest.java | 206 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StaticContentHandlerTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java index cd8965d..f5a0065 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/StaticContentHandler.java @@ -32,6 +32,8 @@ public class StaticContentHandler implements ProxyRouteHandler, ResourceLoaderAw // A lightweight MIME type map for the most common web assets private static final Map MIME_TYPES = new HashMap<>(); static { + MIME_TYPES.put("", "text/plain"); + MIME_TYPES.put("txt", "text/plain"); MIME_TYPES.put("css", "text/css"); MIME_TYPES.put("js", "application/javascript"); MIME_TYPES.put("json", "application/json"); diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StaticContentHandlerTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StaticContentHandlerTest.java new file mode 100644 index 0000000..0af38a2 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/StaticContentHandlerTest.java @@ -0,0 +1,206 @@ +package me.cxdev.commerce.proxy.handler; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; +import io.undertow.util.Methods; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +@ExtendWith(MockitoExtension.class) +class StaticContentHandlerTest { + private StaticContentHandler handler; + + @Mock + private ResourceLoader resourceLoaderMock; + + @Mock + private HttpServerExchange exchangeMock; + + @Mock + private Resource resourceMock; + + private HeaderMap responseHeaders; + + @BeforeEach + void setUp() { + // We use a clean normalized base location for testing + handler = new StaticContentHandler("classpath:ui/public"); + handler.setResourceLoader(resourceLoaderMock); + + responseHeaders = new HeaderMap(); + } + + // --- matches() Tests --- + + @Test + void testMatches_WithValidGetRequest_ReturnsTrue() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.GET); + when(exchangeMock.getRequestPath()).thenReturn("/style.css"); + + when(resourceLoaderMock.getResource("classpath:ui/public/style.css")).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.isReadable()).thenReturn(true); + + assertTrue(handler.matches(exchangeMock), "Should match a valid GET request for a readable file"); + } + + @Test + void testMatches_WithNonGetMethod_ReturnsFalse() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.POST); + + assertFalse(handler.matches(exchangeMock), "Should strictly ignore non-GET methods like POST"); + } + + @Test + void testMatches_WithRootPath_ReturnsFalse() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.GET); + when(exchangeMock.getRequestPath()).thenReturn("/"); + + assertFalse(handler.matches(exchangeMock), "Should ignore the root path '/' to allow index rendering handlers to take over"); + } + + @Test + void testMatches_WithUnreadableOrMissingResource_ReturnsFalse() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.GET); + when(exchangeMock.getRequestPath()).thenReturn("/missing.png"); + + when(resourceLoaderMock.getResource("classpath:ui/public/missing.png")).thenReturn(resourceMock); + + // Simulate missing file + when(resourceMock.exists()).thenReturn(false); + assertFalse(handler.matches(exchangeMock), "Should not match if the resource does not exist"); + + // Simulate existing but unreadable file (e.g., a directory) + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.isReadable()).thenReturn(false); + assertFalse(handler.matches(exchangeMock), "Should not match if the resource is a directory or unreadable"); + } + + // --- handleRequest() Tests --- + + @Test + void testHandleRequest_InIoThread_DispatchesAndReturns() { + when(exchangeMock.isInIoThread()).thenReturn(true); + + handler.handleRequest(exchangeMock); + + // Assert that the handler dispatches itself to a worker thread and immediately returns + // without trying to read paths or resources (which would block the IO thread). + verify(exchangeMock).dispatch(handler); + verify(exchangeMock, never()).getRequestPath(); + } + + @Test + void testHandleRequest_ResourceDisappeared_Returns404() { + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn("/style.css"); + + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + + // Edge case: File existed during matches(), but was deleted right before handleRequest() + when(resourceMock.exists()).thenReturn(false); + + handler.handleRequest(exchangeMock); + + verify(exchangeMock).setStatusCode(404); + verify(exchangeMock, never()).startBlocking(); + } + + @Test + void testHandleRequest_ServesPlainFile() throws Exception { + executeSuccessfulFileDeliveryTest("/file", "text/plain", "Lorem ipsum"); + } + + @Test + void testHandleRequest_ServesCssFile() throws Exception { + executeSuccessfulFileDeliveryTest("/styles/main.css", "text/css", "body { color: red; }"); + } + + @Test + void testHandleRequest_ServesJsFile() throws Exception { + executeSuccessfulFileDeliveryTest("/scripts/app.js", "application/javascript", "console.log('Hello');"); + } + + @Test + void testHandleRequest_ServesUnknownExtension_DefaultsToOctetStream() throws Exception { + executeSuccessfulFileDeliveryTest("/downloads/data.unknown", "application/octet-stream", "raw binary data"); + } + + @Test + void testHandleRequest_OnIoException_Returns500() throws Exception { + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn("/broken.css"); + + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.isReadable()).thenReturn(true); + + Mockito.lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + + // Simulate an IOException while opening the file stream + when(resourceMock.getInputStream()).thenThrow(new IOException("File locked by OS")); + + // Ensure the handler knows the response hasn't been committed yet + when(exchangeMock.isResponseStarted()).thenReturn(false); + + handler.handleRequest(exchangeMock); + + // The handler must catch the exception and return a 500 Internal Server Error + verify(exchangeMock).setStatusCode(500); + } + + /** + * Helper method to simulate a successful file download of a specific type. + */ + private void executeSuccessfulFileDeliveryTest(String requestPath, String expectedMimeType, String fileContent) throws Exception { + // 1. Setup Request Phase + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn(requestPath); + + // 2. Setup Resource Loading + when(resourceLoaderMock.getResource("classpath:ui/public" + requestPath)).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.isReadable()).thenReturn(true); + + // 3. Setup Response Streams & Headers + Mockito.lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + when(exchangeMock.getOutputStream()).thenReturn(outputStream); + + byte[] contentBytes = fileContent.getBytes(); + when(resourceMock.getInputStream()).thenReturn(new ByteArrayInputStream(contentBytes)); + + // 4. Execution + handler.handleRequest(exchangeMock); + + // 5. Assertions + verify(exchangeMock).setStatusCode(200); + verify(exchangeMock).startBlocking(); // Vital for Undertow output streams + + assertEquals(expectedMimeType, responseHeaders.getFirst(Headers.CONTENT_TYPE), + "MIME type must correctly map the file extension"); + assertArrayEquals(contentBytes, outputStream.toByteArray(), + "The file content must be perfectly copied to the Undertow output stream"); + } +} From 47968d51ad7a5e036fab4b9ff26f5bdbb0284457 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 18:04:25 +0100 Subject: [PATCH 18/25] add tests for template rendering handler --- .../handler/TemplateRenderingHandler.java | 6 +- .../handler/TemplateRenderingHandlerTest.java | 217 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/TemplateRenderingHandlerTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java index 235592e..0b9e90f 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java @@ -13,6 +13,7 @@ import io.undertow.util.Headers; import io.undertow.util.Methods; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.MessageSource; @@ -146,6 +147,9 @@ private String resolveI18nMessages(String html, Locale locale) { } String resolvedMessage = messageSource.getMessage(key, null, defaultValue, locale); + if (resolvedMessage == null) { + resolvedMessage = key; + } matcher.appendReplacement(sb, Matcher.quoteReplacement(resolvedMessage)); } matcher.appendTail(sb); @@ -155,7 +159,7 @@ private String resolveI18nMessages(String html, Locale locale) { private Locale determineLocale(HttpServerExchange exchange) { String acceptLanguage = exchange.getRequestHeaders().getFirst(Headers.ACCEPT_LANGUAGE); - if (acceptLanguage != null && !acceptLanguage.isEmpty()) { + if (StringUtils.isNotBlank(acceptLanguage)) { String primaryTag = acceptLanguage.split(",")[0].trim(); try { return Locale.forLanguageTag(primaryTag); diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/TemplateRenderingHandlerTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/TemplateRenderingHandlerTest.java new file mode 100644 index 0000000..27046da --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/handler/TemplateRenderingHandlerTest.java @@ -0,0 +1,217 @@ +package me.cxdev.commerce.proxy.handler; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import de.hybris.platform.servicelayer.config.ConfigurationService; + +import io.undertow.io.Sender; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; +import io.undertow.util.Methods; + +import org.apache.commons.configuration2.Configuration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.MessageSource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +@ExtendWith(MockitoExtension.class) +class TemplateRenderingHandlerTest { + + private TemplateRenderingHandler handler; + + @Mock + private ConfigurationService configurationServiceMock; + + @Mock + private Configuration configurationMock; + + @Mock + private MessageSource messageSourceMock; + + @Mock + private ResourceLoader resourceLoaderMock; + + @Mock + private HttpServerExchange exchangeMock; + + @Mock + private Resource resourceMock; + + @Mock + private Sender senderMock; + + private HeaderMap requestHeaders; + private HeaderMap responseHeaders; + + @BeforeEach + void setUp() { + handler = new TemplateRenderingHandler("classpath:ui/templates", configurationServiceMock, messageSourceMock); + handler.setResourceLoader(resourceLoaderMock); + + requestHeaders = new HeaderMap(); + responseHeaders = new HeaderMap(); + + // Lenient mocks for standard exchange behavior + Mockito.lenient().when(exchangeMock.getRequestHeaders()).thenReturn(requestHeaders); + Mockito.lenient().when(exchangeMock.getResponseHeaders()).thenReturn(responseHeaders); + Mockito.lenient().when(exchangeMock.getResponseSender()).thenReturn(senderMock); + Mockito.lenient().when(configurationServiceMock.getConfiguration()).thenReturn(configurationMock); + } + + // --- matches() Tests --- + + @Test + void testMatches_WithValidHtmlGetRequest_ReturnsTrue() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.GET); + when(exchangeMock.getRequestPath()).thenReturn("/login.html"); + + when(resourceLoaderMock.getResource("classpath:ui/templates/login.html")).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.isReadable()).thenReturn(true); + + assertTrue(handler.matches(exchangeMock), "Should match GET requests for existing .html files"); + } + + @Test + void testMatches_WithNonHtmlExtension_ReturnsFalse() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.GET); + when(exchangeMock.getRequestPath()).thenReturn("/style.css"); + + assertFalse(handler.matches(exchangeMock), "Should ignore non-html files (handled by StaticContentHandler)"); + } + + @Test + void testMatches_WithNonGetMethod_ReturnsFalse() { + when(exchangeMock.getRequestMethod()).thenReturn(Methods.POST); + + assertFalse(handler.matches(exchangeMock), "Should strictly ignore POST/PUT requests"); + } + + // --- handleRequest() Tests --- + + @Test + void testHandleRequest_InIoThread_DispatchesAndReturns() { + when(exchangeMock.isInIoThread()).thenReturn(true); + + handler.handleRequest(exchangeMock); + + // Assert that the handler dispatches itself to a worker thread to prevent blocking + verify(exchangeMock).dispatch(handler); + verify(exchangeMock, never()).getRequestPath(); + } + + @Test + void testHandleRequest_ResourceDisappeared_Returns404() { + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn("/vanished.html"); + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(false); + + handler.handleRequest(exchangeMock); + + verify(exchangeMock).setStatusCode(404); + verify(senderMock).send("404 - Template not found"); + } + + @Test + void testHandleRequest_RendersPropertiesAndI18nMessages() throws Exception { + // Setup: Request + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn("/index.html"); + requestHeaders.put(Headers.ACCEPT_LANGUAGE, "de-DE,de;q=0.9,en;q=0.8"); + + // Setup: Mock Resource and raw HTML content + String rawHtml = "
%{my.property}
" + + "%{missing.prop:DefaultFallback} " + + "

#{page.title}

" + + "

#{missing.msg:Default Msg}

"; + + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getInputStream()).thenReturn(new ByteArrayInputStream(rawHtml.getBytes(StandardCharsets.UTF_8))); + + // Setup: Mock Configuration properties + when(configurationMock.getString("my.property", null)).thenReturn("ResolvedPropValue"); + when(configurationMock.getString("missing.prop", "DefaultFallback")).thenReturn("DefaultFallback"); // Simulating + // missing + // prop + + // Setup: Mock i18n messages (expecting German locale based on header) + Locale expectedLocale = Locale.forLanguageTag("de-DE"); + when(messageSourceMock.getMessage(eq("page.title"), isNull(), eq("page.title"), eq(expectedLocale))) + .thenReturn("CX Dev Proxy - Mock Login"); + when(messageSourceMock.getMessage(eq("missing.msg"), isNull(), eq("Default Msg"), eq(expectedLocale))) + .thenReturn("Default Msg"); // Simulating fallback + + // Execution + handler.handleRequest(exchangeMock); + + // Assertions + verify(exchangeMock).setStatusCode(200); + + ArgumentCaptor htmlCaptor = ArgumentCaptor.forClass(String.class); + verify(senderMock).send(htmlCaptor.capture()); + + String renderedHtml = htmlCaptor.getValue(); + + // Assert Property Resolution + assertTrue(renderedHtml.contains("
ResolvedPropValue
"), "Should resolve known properties"); + assertTrue(renderedHtml.contains("DefaultFallback"), "Should use property default values if provided"); + + // Assert i18n Resolution + assertTrue(renderedHtml.contains("

CX Dev Proxy - Mock Login

"), "Should resolve known i18n messages in correct locale"); + assertTrue(renderedHtml.contains("

Default Msg

"), "Should use i18n default values if provided"); + } + + @Test + void testHandleRequest_OnIoException_Returns500() throws Exception { + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn("/error.html"); + + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getInputStream()).thenThrow(new IOException("Disk read error")); + + handler.handleRequest(exchangeMock); + + verify(exchangeMock).setStatusCode(500); + verify(senderMock).send("500 - Internal Server Error rendering template"); + } + + @Test + void testDetermineLocale_InvalidHeader_DefaultsToEnglish() throws Exception { + // Setup request with completely broken language header + when(exchangeMock.isInIoThread()).thenReturn(false); + when(exchangeMock.getRequestPath()).thenReturn("/test.html"); + requestHeaders.put(Headers.ACCEPT_LANGUAGE, null); + + when(resourceLoaderMock.getResource(anyString())).thenReturn(resourceMock); + when(resourceMock.exists()).thenReturn(true); + when(resourceMock.getInputStream()).thenReturn(new ByteArrayInputStream("#{test.msg}".getBytes(StandardCharsets.UTF_8))); + + handler.handleRequest(exchangeMock); + + // Verify that the MessageSource is called with Locale.ENGLISH as the ultimate fallback + verify(messageSourceMock).getMessage(anyString(), isNull(), anyString(), eq(Locale.ENGLISH)); + } +} From 892e8759d89b330574f9a33ac58612a029ba6927 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 2 Mar 2026 18:15:16 +0100 Subject: [PATCH 19/25] update documentation --- .../custom/cxdevtools/cxdevproxy/README.md | 154 ++++++++++-------- 1 file changed, 83 insertions(+), 71 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md index 289f6f2..68d6db0 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md @@ -1,23 +1,17 @@ # CX Dev Proxy -The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a -transparent reverse proxy in front of your local SAP Commerce Tomcat instance, solving the most common frontend and -headless development pain points out-of-the-box. +The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a transparent reverse proxy in front of your local SAP Commerce Tomcat instance, solving the most common frontend and headless development pain points out-of-the-box. + + ## 🚀 Key Features -* **Dynamic Groovy DSL & Hot-Reloading:** Define routing rules and HTTP modifications using a highly readable, fluent - Groovy DSL. Save the script and the proxy updates instantly with **zero downtime**—no server restarts required! -* **Clean Interceptor Architecture:** A strict separation between Request Routing (Handlers) and Request Modification ( - Interceptors) ensures predictable, thread-safe request manipulation. -* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's - native `jwkSource`, meaning tokens are fully trusted by SAP Commerce without any backend configuration. -* **Developer Auth Portal:** An out-of-the-box, bilingual (English/German) UI portal (`/proxy/login.html`) to easily - switch between mock Employee, B2C, and B2B user contexts. -* **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming - Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). -* **Conflict-Free Configuration:** Securely injects Spring backend properties into frontend templates using a custom - `%{property:default}` syntax, eliminating collisions with modern JavaScript. +* **Dynamic Groovy DSL & Hot-Reloading:** Define routing rules and HTTP modifications using a highly readable, fluent Groovy DSL. Save the script and the proxy updates instantly with **zero downtime**—no server restarts required! +* **Clean Interceptor Architecture:** A strict separation between Request Routing (Conditions) and Request Modification (Interceptors) ensures predictable, thread-safe request manipulation. +* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's native `jwkSource`, meaning tokens are fully trusted by SAP Commerce without any backend configuration. +* **Developer Auth Portal:** An out-of-the-box, bilingual UI portal (`/proxy/login.html`, supporting English, German, and more via `Accept-Language`) to easily switch between mock Employee, B2C, and B2B user contexts. +* **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). +* **Conflict-Free Configuration:** Securely injects Spring backend properties and i18n messages into frontend templates using custom `%` and `#` syntax, eliminating collisions with modern JavaScript. --- @@ -50,7 +44,8 @@ cxdevproxy.proxy.ui.login.showB2B=true # Specifies the base path where the proxy looks for JWT claim templates (JSON files). cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt -# Defines the validity duration of the generated mock JWT tokens (e.g., 3600s, 60m, 10h, 1d). +# Defines the validity duration of the generated mock JWT tokens. +# Supported formats: 500ms, 60s, 10m, 10h, 1d (Defaults to ms if no unit is provided) cxdevproxy.proxy.jwt.validity=10h ``` @@ -60,13 +55,14 @@ cxdevproxy.proxy.jwt.validity=10h The extension provides a built-in UI to set mock user cookies. You can access it via `/proxy/login.html`. -To prevent syntax collisions between Spring property resolution and modern JavaScript template literals (`${...}`), the -HTML templates utilize a custom, robust placeholder syntax: **`%{property.key:defaultValue}`**. +To prevent syntax collisions between Spring property resolution and modern JavaScript template literals (`${...}`), the HTML templates utilize a custom, robust placeholder syntax: +* **`%{property.key:defaultValue}`** for Spring Configuration Properties +* **`#{i18n.message.key:Default Text}`** for localized Message Bundles This allows you to safely toggle UI elements based on your backend configuration without breaking frontend scripts: ```javascript -// Safely resolved by the proxy's ConfigurationService before reaching the browser +// Safely resolved by the proxy's TemplateRenderingHandler before reaching the browser const showB2C = %{cxdevproxy.proxy.ui.login.showB2C:false}; const showB2B = %{cxdevproxy.proxy.ui.login.showB2B:false}; ``` @@ -75,29 +71,38 @@ const showB2B = %{cxdevproxy.proxy.ui.login.showB2B:false}; ## 🧩 Building Routing Rules (The Groovy DSL) -Instead of verbose XML, the CX Dev Proxy uses a powerful Groovy Domain Specific Language (DSL). The scripts are -hot-reloaded the moment you save them. +Instead of verbose XML, the CX Dev Proxy uses a powerful Groovy Domain Specific Language (DSL). The scripts are hot-reloaded the moment you save them. + -To provide the best Developer Experience, our rule engine **automatically imports** all interceptors and fluent -condition factories (`Conditions.*`), and binds existing Spring beans to the script context. -### 1. Fluent API +To provide the best Developer Experience, our rule engine **automatically imports** all fluent condition factories (`Conditions.*`) and interceptor builders (`Interceptors.*`), and dynamically binds existing Spring beans to the script context. -You can build complex interceptor conditions using our functional, AssertJ-style API. +### 1. Fluent API (Conditions & Interceptors) -* `isMethod("GET")`, `pathStartsWith("/occ")`, `pathMatches("/occ/v2/**")` +You can build complex routes using our functional, AssertJ-style API directly in the script: + +**Conditions:** +* `isMethod("GET")`, `pathStartsWith("/occ")`, `pathMatches("/occ/v2/**")`, `pathRegexMatches(".*")` * `hasHeader("Authorization")`, `hasCookie("cxdevproxy_user_id")`, `hasParameter("username")` -* **Logical Operators:** `and(...)`, `or(...)`, `not(...)` — which can be chained (`isOcc.and(hasMockUser)`) or nested ( - `and(isOcc, hasMockUser)`). +* **Logical Operators:** `and(...)`, `or(...)`, `not(...)`, `always()`, `never()` + +**Inline Interceptors:** +* `jsonResponse(200, '{"status":"ok"}')` +* `htmlResponse("

Hello

")` +* `networkDelay("800ms")` or `networkDelay("1s", "3s")` + +### 2. Pre-configured Spring Variables & Magic Naming -### 2. Pre-configured Spring Variables +The script environment is automatically populated with context-aware variables (derived from your Spring XML) to make routing effortless. -The script environment is automatically populated with context-aware variables (derived from your Spring XML) to make -routing even easier: +**Custom Bean Naming Convention:** +If you write your own Spring beans for Conditions or Interceptors, follow this prefix convention. The proxy engine will automatically strip the prefix and bind the bean using lower-camel-case: +* Bean `cxdevproxyConditionIsOcc` becomes `isOcc` in Groovy. +* Bean `cxdevproxyInterceptorCxJwtInjector` becomes `cxJwtInjector` in Groovy. -* **Paths:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer` -* **State:** `hasMockUser`, `hasAuthorizationHeader` -* **Available Interceptors:** `cxForwardedHeadersInterceptor`, `cxJwtInjectorInterceptor`, `cxCorsInjectorInterceptor` +**Available Pre-bound Variables:** +* **Conditions:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer`, `hasMockUser`, `hasAuthorizationHeader` +* **Interceptors:** `cxForwardedHeadersInterceptor`, `cxJwtInjectorInterceptor`, `cxCorsInjectorInterceptor` --- @@ -110,7 +115,7 @@ cxdevproxy.proxy.backend.rules=classpath:path/to/your/project/my-backend-rules.g cxdevproxy.proxy.frontend.rules=classpath:path/to/your/project/my-frontend-rules.groovy ``` -Then, simply create and edit these Groovy files. Every script must return a list of interceptors. +Every script must return a `List`. Rules are evaluated top-to-bottom. ### 1. The Baseline (Default Script) @@ -118,33 +123,47 @@ The most basic setup simply applies standard proxy headers unconditionally: ```groovy return [ - cxForwardedHeadersInterceptor +cxForwardedHeadersInterceptor ] ``` ### 2. Conditional Execution (The Builder Pattern) -You should never mutate the state of injected Spring beans directly. Instead, wrap them using the stateless -`interceptor()` builder to apply them conditionally. +You should never mutate the state of injected Spring beans directly. Instead, wrap them using the stateless `interceptor()` builder to apply them conditionally. ```groovy -// Combine pre-configured conditions seamlessly -def jwtCondition = isOcc.or(isSmartEdit) - .and(hasMockUser) - .and(not(hasAuthorizationHeader)) +// 1. Combine pre-configured conditions seamlessly using static logical operators +def jwtCondition = and( +or(isOcc, isSmartEdit), +hasMockUser, +not(hasAuthorizationHeader) +) + +// 2. Simple API Mocking with inline JSON +def mockCartCall = interceptor() +.constrainedBy( pathMatches("/**/carts/current"), isMethod("GET") ) +.perform( jsonResponse('{"type": "cartWsDTO", "totalItems": 5}') ) + +// 3. Simulating Latency +def slowCheckout = interceptor() +.constrainedBy( pathMatches("/**/orders"), isMethod("POST") ) +.perform( networkDelay("1s", "3s") ) return [ - cxForwardedHeadersInterceptor, // Always execute - - // Execute JWT Injector only if the complex condition is met - interceptor() - .constrainedBy(jwtCondition) - .perform(cxJwtInjectorInterceptor), - - // Execute CORS Injector only for OCC requests - interceptor() - .constrainedBy(isOcc) - .perform(cxCorsInjectorInterceptor) +cxForwardedHeadersInterceptor, // Always execute + + // Execute JWT Injector only if the complex condition is met + interceptor() + .constrainedBy(jwtCondition) + .perform(cxJwtInjectorInterceptor), + + // Execute CORS Injector only for OCC requests + interceptor() + .constrainedBy(isOcc) + .perform(cxCorsInjectorInterceptor), + + mockCartCall, + slowCheckout ] ``` @@ -152,25 +171,18 @@ return [ ## 🔑 JWT Mocking Deep Dive -The `cxJwtInjectorInterceptor` is the heart of the local authentication bypass. -It intercepts requests (when conditions match) and injects a dynamically signed JWT. +The `cxJwtInjectorInterceptor` is the heart of the local authentication bypass. It intercepts requests (when conditions match) and injects a dynamically signed JWT. + + > **âš ï¸ IMPORTANT: OAuth Client ID Requirement** -> By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP -> Commerce default (which uses `mobile_android` for OCC). -> To make the mock tokens work, you must ensure an OAuth Client with the ID `storefront` is created in your local SAP -> Commerce database (via ImpEx). -> We understand that this might be questionable, but we are convinced that using a self-explaining `client_id` is a -> best-practise and should be enforced anyway. If you don't agree, feel free to change the templates to your needs. - -1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the exact same one used by the - `authorizationserver`). This means the backend trusts the generated tokens implicitly. No extra backend configuration - needed! -2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and - `user_id=john@example.com`, it looks for a template at: +> By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP Commerce default (which uses `mobile_android` for OCC). +> To make the mock tokens work, you must ensure an OAuth Client with the ID `storefront` is created in your local SAP Commerce database (via ImpEx). +> We understand that this might be questionable, but we are convinced that using a self-explaining `client_id` is a best-practise and should be enforced anyway. If you don't agree, feel free to change the templates to your needs. + +1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the exact same one used by the `authorizationserver`). This means the backend trusts the generated tokens implicitly. No extra backend configuration needed! +2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and `user_id=john@example.com`, it looks for a template at: `classpath:cxdevproxy/jwt/customer/john@example.com.json` -3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on - `cxdevproxy.proxy.jwt.validity`. +3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on `cxdevproxy.proxy.jwt.validity` and automatically cache-managed to prevent mid-flight expirations. -To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=path/to/your/custom/templates` -in your local properties. +To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=path/to/your/custom/templates` in your local properties. \ No newline at end of file From d4292199ab945694308ed4074f386331e7194ecb Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Wed, 4 Mar 2026 17:47:44 +0100 Subject: [PATCH 20/25] fix double classpath: notation due to use of ClasspathUtils --- .../cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java index 97daa13..9a4f3b8 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java @@ -101,7 +101,7 @@ private void initGroovyShell() { */ public File resolveScriptFile(String locationPath) { try { - Resource resource = resourceLoader.getResource("classpath:" + locationPath); + Resource resource = resourceLoader.getResource(locationPath); if (resource.exists()) { // This works flawlessly in local Hybris because extensions are exploded folders return resource.getFile(); From 55137ff1aef9957871e398bab7cdcf657783c6e8 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Wed, 4 Mar 2026 17:49:14 +0100 Subject: [PATCH 21/25] switch to HTTP by default, ssl needs activation --- .../bin/custom/cxdevtools/cxdevproxy/project.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties index 9245097..58b6d81 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties @@ -9,7 +9,7 @@ cxdevproxy.enabled=false # SSL / Keystore Configuration # Defines the keystore details used for securing the proxy server via HTTPS. -cxdevproxy.ssl.enabled=true +cxdevproxy.ssl.enabled=false cxdevproxy.ssl.keystore.path=${HYBRIS_CONFIG_DIR}/../../../certificates/local.cxdev.me.p12 cxdevproxy.ssl.keystore.password=123456 cxdevproxy.ssl.keystore.alias=local.cxdev.me @@ -17,7 +17,7 @@ cxdevproxy.ssl.keystore.alias=local.cxdev.me # Undertow Server Binding # Defines the network interface, hostname, and port the embedded proxy will listen on. cxdevproxy.server.bindaddress=127.0.0.1 -cxdevproxy.server.protocol=https +cxdevproxy.server.protocol=http cxdevproxy.server.hostname=local.cxdev.me cxdevproxy.server.port=8080 @@ -27,7 +27,7 @@ cxdevproxy.proxy.rules.reloadinterval=5s # ----------------------------------------------------------------------- # CX Dev Proxy - Static Files (Target) # ----------------------------------------------------------------------- -cxdevproxy.proxy.ui.login.showB2C=false +cxdevproxy.proxy.ui.login.showB2C=true cxdevproxy.proxy.ui.login.showB2B=true cxdevproxy.proxy.ui.baselocation=cxdevproxy/ui cxdevproxy.proxy.ui.messages.basename=cxdevproxy/i18n/messages From 7893a3c7a99bf9c1b9397fa881b8ddd241230ca2 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Wed, 4 Mar 2026 17:49:47 +0100 Subject: [PATCH 22/25] use short notation in groovy rules and in documentation --- .../custom/cxdevtools/cxdevproxy/README.md | 28 ++++++++----------- .../rulesets/cxdevproxy-backend-rules.groovy | 8 ++++-- .../rulesets/cxdevproxy-frontend-rules.groovy | 2 +- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md index 68d6db0..ac2f307 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md @@ -102,7 +102,7 @@ If you write your own Spring beans for Conditions or Interceptors, follow this p **Available Pre-bound Variables:** * **Conditions:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer`, `hasMockUser`, `hasAuthorizationHeader` -* **Interceptors:** `cxForwardedHeadersInterceptor`, `cxJwtInjectorInterceptor`, `cxCorsInjectorInterceptor` +* **Interceptors:** `forwardedHeaders`, `jwtInjector`, `corsInjector` --- @@ -123,7 +123,7 @@ The most basic setup simply applies standard proxy headers unconditionally: ```groovy return [ -cxForwardedHeadersInterceptor + forwardedHeaders ] ``` @@ -133,34 +133,30 @@ You should never mutate the state of injected Spring beans directly. Instead, wr ```groovy // 1. Combine pre-configured conditions seamlessly using static logical operators -def jwtCondition = and( -or(isOcc, isSmartEdit), -hasMockUser, -not(hasAuthorizationHeader) -) +def jwtCondition = and(or(isOcc, isSmartEdit), hasMockUser, not(hasAuthorizationHeader)) // 2. Simple API Mocking with inline JSON def mockCartCall = interceptor() -.constrainedBy( pathMatches("/**/carts/current"), isMethod("GET") ) -.perform( jsonResponse('{"type": "cartWsDTO", "totalItems": 5}') ) + .constrainedBy( isMethod("GET"), pathMatches("/**/carts/current") ) + .perform( jsonResponse('{"type": "cartWsDTO", "totalItems": 5}') ) // 3. Simulating Latency def slowCheckout = interceptor() -.constrainedBy( pathMatches("/**/orders"), isMethod("POST") ) -.perform( networkDelay("1s", "3s") ) + .constrainedBy( isMethod("POST"), pathMatches("/**/orders") ) + .perform( networkDelay("1s", "3s") ) return [ -cxForwardedHeadersInterceptor, // Always execute + forwardedHeaders, // Always execute // Execute JWT Injector only if the complex condition is met interceptor() .constrainedBy(jwtCondition) - .perform(cxJwtInjectorInterceptor), + .perform(jwtInjector), // Execute CORS Injector only for OCC requests interceptor() .constrainedBy(isOcc) - .perform(cxCorsInjectorInterceptor), + .perform(corsInjector), mockCartCall, slowCheckout @@ -171,9 +167,7 @@ cxForwardedHeadersInterceptor, // Always execute ## 🔑 JWT Mocking Deep Dive -The `cxJwtInjectorInterceptor` is the heart of the local authentication bypass. It intercepts requests (when conditions match) and injects a dynamically signed JWT. - - +The `jwtInjector` is the heart of the local authentication bypass. It intercepts requests (when conditions match) and injects a dynamically signed JWT. > **âš ï¸ IMPORTANT: OAuth Client ID Requirement** > By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP Commerce default (which uses `mobile_android` for OCC). diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy index 6ee0373..8235f6c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy @@ -1,3 +1,5 @@ -def interceptor = [] -interceptor << cxForwardedHeadersInterceptor -return interceptor \ No newline at end of file +return [ + interceptor() + .constrainedBy( isMethod("GET"), pathMatches("/**/carts/current") ) + .perform( jsonResponse('{"type": "cartWsDTO", "totalItems": 5}') ) +] \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy index 6ee0373..43e25a7 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy @@ -1,3 +1,3 @@ def interceptor = [] -interceptor << cxForwardedHeadersInterceptor +interceptor << forwardedHeaders return interceptor \ No newline at end of file From 9480233c7529198e64ef6d3a9aedc460a3f56684 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Wed, 4 Mar 2026 18:02:29 +0100 Subject: [PATCH 23/25] stop processing of request, once processing has started or is completed --- .../proxy/livecycle/UndertowProxyManager.java | 41 +++++-------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java index 2ac390e..22af03d 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/UndertowProxyManager.java @@ -217,10 +217,10 @@ public void start() { .setConnectionsPerThread(20); HttpHandler baseFrontendHandler = ProxyHandler.builder().setProxyClient(frontendClient).build(); - HttpHandler baseBackendHandler = ProxyHandler.builder().setProxyClient(backendClient).setMaxRequestTime(30000).build(); + HttpHandler finalFrontendHandler = applyRules(frontendHandlersRef.get(), baseFrontendHandler); - HttpHandler finalFrontendHandler = applyFrontendRules(baseFrontendHandler); - HttpHandler finalBackendHandler = applyBackendRules(baseBackendHandler); + HttpHandler baseBackendHandler = ProxyHandler.builder().setProxyClient(backendClient).setMaxRequestTime(30000).build(); + HttpHandler finalBackendHandler = applyRules(backendHandlersRef.get(), baseBackendHandler); List activeBackendContexts = determineBackendContexts(); LOG.info("Active backend routing contexts: {}", activeBackendContexts); @@ -304,9 +304,8 @@ private List determineBackendContexts() { * @throws Exception If an error occurs during routing. */ private void routeRequest(HttpServerExchange exchange, List backendContexts, HttpHandler backendHandler, HttpHandler frontendHandler) throws Exception { - String path = exchange.getRequestPath(); + String path = StringUtils.stripToEmpty(exchange.getRequestPath()); boolean isBackendRequest = backendContexts.stream().anyMatch(path::startsWith); - if (isBackendRequest) { LOG.debug("Serving request {} {} with backend handler.", exchange.getRequestMethod(), exchange.getRequestURI()); backendHandler.handleRequest(exchange); @@ -317,42 +316,24 @@ private void routeRequest(HttpServerExchange exchange, List backendConte } /** - * Wraps the base frontend handler with any configured custom interceptors/handlers. - * Pulls the handlers atomically from the reference to ensure thread-safety during hot-reloads. + * Wraps the base handler with any configured custom interceptors/handlers. * + * @param interceptors The custom interceptors. * @param next The base proxy handler. * @return A chained HTTP handler applying all configured frontend rules. */ - protected HttpHandler applyFrontendRules(HttpHandler next) { + protected HttpHandler applyRules(List interceptors, HttpHandler next) { return exchange -> { - List currentHandlers = frontendHandlersRef.get(); - for (ProxyExchangeInterceptor handler : emptyIfNull(currentHandlers)) { - handler.apply(exchange); + for (ProxyExchangeInterceptor interceptor : emptyIfNull(interceptors)) { + interceptor.apply(exchange); if (exchange.isResponseStarted() || exchange.isComplete()) { break; } } - next.handleRequest(exchange); - }; - } - /** - * Wraps the base backend handler with any configured custom interceptors/handlers. - * Pulls the handlers atomically from the reference to ensure thread-safety during hot-reloads. - * - * @param next The base proxy handler. - * @return A chained HTTP handler applying all configured backend rules. - */ - protected HttpHandler applyBackendRules(HttpHandler next) { - return exchange -> { - List currentHandlers = backendHandlersRef.get(); - for (ProxyExchangeInterceptor handler : emptyIfNull(currentHandlers)) { - handler.apply(exchange); - if (exchange.isResponseStarted() || exchange.isComplete()) { - break; - } + if (!exchange.isResponseStarted() && !exchange.isComplete()) { + next.handleRequest(exchange); } - next.handleRequest(exchange); }; } From d18f9e133aae1e7757a9ed00e74e9699b70f75cc Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Wed, 4 Mar 2026 18:17:21 +0100 Subject: [PATCH 24/25] fix broken links in documentation --- README.md | 14 +++++++------- .../custom/cxdevtools/cxdevproxy/CONTRIBUTING.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2a2118b..deaa5ed 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # CX DEV Workspace -[![Build & Test](https://github.com/cxdevtools/workspace/actions/workflows/buildandtest.yml/badge.svg)](https://github.com/cxdevtools/workspace/actions/workflows/buildandtest.yml) -[![Code Analysis](https://github.com/cxdevtools/workspace/actions/workflows/code-analysis.yml/badge.svg)](https://github.com/cxdevtools/workspace/actions/workflows/code-analysis.yml) -[![Code Coverage](https://codecov.io/gh/cxdevtools/workspace/branch/main/graph/badge.svg?token=F1BIK8R7NZ)](https://codecov.io/gh/cxdevtools/workspace) -[![Dependency Check](https://github.com/cxdevtools/workspace/actions/workflows/dependency-check.yml/badge.svg)](https://github.com/cxdevtools/workspace/actions/workflows/dependency-check.yml) +[![Build & Test](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/buildandtest.yml/badge.svg)](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/buildandtest.yml) +[![Code Analysis](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/code-analysis.yml/badge.svg)](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/code-analysis.yml) +[![Code Coverage](https://codecov.io/gh/cxdevtools/sap-commerce-cloud/branch/main/graph/badge.svg?token=F1BIK8R7NZ)](https://codecov.io/gh/cxdevtools/sap-commerce-cloud) +[![Dependency Check](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/dependency-check.yml/badge.svg)](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/dependency-check.yml) All extensions available in this repository are built with high test coverage and do not influence the behavior of your project without changes to your configuration. This is guaranteed and intended by design, because the extensions @@ -14,8 +14,8 @@ configuration parameter. ## Contributing Contributions are both welcomed and appreciated. For specific guidelines regarding contributions, please see -[CONTRIBUTING.md](https://github.com/cxdevtools/workspace/blob/main/CONTRIBUTING.md) in the root directory of the +[CONTRIBUTING.md](https://github.com/cxdevtools/sap-commerce-cloud/blob/main/CONTRIBUTING.md) in the root directory of the project. Those willing to use milestone or SNAPSHOT releases are encouraged to file feature requests and bug reports -using the project's [issue tracker](https://github.com/cxdevtools/workspace/issues). Issues marked with an -`help wanted` label are specifically +using the project's [issue tracker](https://github.com/cxdevtools/sap-commerce-cloud/issues). Issues marked with an +`help wanted` label are specifically targeted for community contributions. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md index 572289d..98e11a6 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md @@ -1,4 +1,4 @@ This repository has a special setup for contributing. -Please read the [CONTRIBUTING.md from the extensions repository](https://github.com/sapcxtools/workspace/blob/main/CONTRIBUTING.md) which +Please read the [CONTRIBUTING.md from the extensions repository](https://github.com/cxdevtools/sap-commerce-cloud/blob/main/CONTRIBUTING.md) which will guide you through the process. From 996103519d31f0407213da7e44a5d03e4114be77 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Thu, 5 Mar 2026 11:16:45 +0100 Subject: [PATCH 25/25] set release version of extensions to 5.0.1 --- .../bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevbackoffice/external-dependencies.xml | 2 +- .../bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevenvconfig/external-dependencies.xml | 2 +- .../hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml | 2 +- .../bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml | 2 +- .../bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevreporting/external-dependencies.xml | 2 +- .../hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml | 2 +- .../custom/cxdevtools/cxdevtoolkit/external-dependencies.xml | 2 +- sonar-project.properties | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml index 9f08e0a..2a35569 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml index 7e8609b..b86b779 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevbackoffice - 5.1.0-snapshot + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml index 04b27b7..116068c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml @@ -1,7 +1,7 @@ + name="cxdevenvconfig" version="5.0.1" usemaven="true"> diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml index 09e3337..e3c9db1 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevenvconfig - 5.1.0-snapshot + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml index 48e1319..f663b49 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml index 82ac552..82f7db5 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevproxy - 5.1.0-snapshot + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml index f45050c..a0b8b68 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml index 4b826ae..c5261aa 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevreporting - 5.1.0-snapshot + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml index ff3da32..aaa5c53 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/extensioninfo.xml @@ -1,7 +1,7 @@ + name="cxdevtoolkit" version="5.0.1" usemaven="true"> diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml index 50ca88d..72e225e 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevtoolkit/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevtoolkit - 5.1.0-snapshot + 5.0.1 jar diff --git a/sonar-project.properties b/sonar-project.properties index addbe04..635b425 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.organization=cxdevtools # This is the name and version displayed in the SonarCloud UI. sonar.projectName=cxdevtools-workspace -sonar.projectVersion=5.1.0-snapshot +sonar.projectVersion=5.0.1 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. sonar.sources=core-customize/hybris/bin/custom/cxdevtools