+
+
b2bcustomer
#{user.b2b.customer:B2B Customer}
-
-
besteller
Moritz Besteller
+
+
b2bapprover
#{user.b2b.approver:B2B Approver}
-
-
training
Franz Training
+
+
b2bmanager
#{user.b2b.manager:B2B Manager}
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/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..044b7f4
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/jwt/service/CxJwtTokenService.java
@@ -0,0 +1,187 @@
+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.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;
+
+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.
+ *
+ * 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) {
+ this.templatePathPrefix = ResourcePathUtils.normalizeDirectoryPath(templatePathPrefix, "Template path in JwtTokenService");
+ }
+
+ public void setTokenValidity(String validity) {
+ this.tokenValidityMs = TimeUtils.parseIntervalToMillis(validity, "Token validity for JwtTokenService");
+ }
+
+ 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/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/NotCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/NotCondition.java
deleted file mode 100644
index 6f8e2a8..0000000
--- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/NotCondition.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package me.cxdev.commerce.proxy.condition;
-
-import io.undertow.server.HttpServerExchange;
-
-/**
- * A logical NOT condition that negates the result of a single underlying condition.
- */
-public class NotCondition implements ExchangeCondition {
- private ExchangeCondition condition;
-
- @Override
- public boolean matches(HttpServerExchange exchange) {
- if (condition == null) {
- return false; // Fail-safe if not properly configured
- }
- 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/condition/OrCondition.java
deleted file mode 100644
index d38e70e..0000000
--- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/condition/OrCondition.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package me.cxdev.commerce.proxy.condition;
-
-import java.util.List;
-
-import io.undertow.server.HttpServerExchange;
-
-/**
- * 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;
-
- @Override
- public boolean matches(HttpServerExchange exchange) {
- if (conditions == null || conditions.isEmpty()) {
- return false;
- }
- 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/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/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/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..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
@@ -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,10 +26,9 @@
* 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";
+ 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;
@@ -82,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/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..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
@@ -1,74 +1,118 @@
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("", "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");
+ 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..0b9e90f
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/TemplateRenderingHandler.java
@@ -0,0 +1,177 @@
+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.apache.commons.lang3.StringUtils;
+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);
+ if (resolvedMessage == null) {
+ resolvedMessage = key;
+ }
+ 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 (StringUtils.isNotBlank(acceptLanguage)) {
+ 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..06ffc3a
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/i18n/ClasspathMergingMessageSource.java
@@ -0,0 +1,150 @@
+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) {
+ 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
+ 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/interceptor/CorsInjectorInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java
new file mode 100644
index 0000000..7f81114
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/CorsInjectorInterceptor.java
@@ -0,0 +1,61 @@
+package me.cxdev.commerce.proxy.interceptor;
+
+import io.undertow.server.HttpServerExchange;
+import io.undertow.util.Headers;
+import io.undertow.util.HttpString;
+
+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
+ * back to the client. If no Origin header is present in the request, no CORS headers
+ * are injected.
+ */
+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;
+
+ @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 (HttpMethod.OPTIONS.equalsIgnoreCase(exchange.getRequestMethod().toString())) {
+ 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/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/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/handler/JwtInjectorHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptor.java
similarity index 80%
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 4a8b7d5..fd42e5c 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;
@@ -8,19 +8,19 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import me.cxdev.commerce.proxy.livecycle.ProxyHttpServerExchangeHandler;
-import me.cxdev.commerce.proxy.service.JwtTokenService;
+import me.cxdev.commerce.jwt.service.CxJwtTokenService;
+import me.cxdev.commerce.jwt.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.
*
*/
-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";
@@ -33,10 +33,11 @@ public class JwtInjectorHandler implements ProxyHttpServerExchangeHandler {
*/
@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/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java
new file mode 100644
index 0000000..c514b91
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/NetworkDelayInterceptor.java
@@ -0,0 +1,70 @@
+package me.cxdev.commerce.proxy.interceptor;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+import io.undertow.server.HttpServerExchange;
+
+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.
+ *
+ * Supports a randomized delay between a configured minimum and maximum value.
+ * Note: Uses Thread.sleep() which blocks the current worker thread.
+ *
+ */
+class NetworkDelayInterceptor implements ProxyExchangeInterceptor {
+ private static final Logger LOG = LoggerFactory.getLogger(NetworkDelayInterceptor.class);
+
+ 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, "Network delay interceptor interval");
+ this.maxDelayInMillis = this.minDelayInMillis;
+ }
+
+ NetworkDelayInterceptor(String minDelay, String maxDelay) {
+ this.minDelayInMillis = TimeUtils.parseIntervalToMillis(minDelay, "Network minimum delay interceptor interval");
+ ;
+ this.maxDelayInMillis = TimeUtils.parseIntervalToMillis(maxDelay, "Network maximum delay interceptor interval");
+ ;
+ }
+
+ @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);
+ }
+}
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/handler/ConditionalDelegateHandler.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java
similarity index 51%
rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/handler/ConditionalDelegateHandler.java
rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/ProxyInterceptor.java
index 240ab0f..dea9178 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/interceptor/ProxyInterceptor.java
@@ -1,4 +1,4 @@
-package me.cxdev.commerce.proxy.handler;
+package me.cxdev.commerce.proxy.interceptor;
import java.util.List;
@@ -7,26 +7,32 @@
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
+ * 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 ConditionalDelegateHandler implements ProxyHttpServerExchangeHandler {
- private static final Logger LOG = LoggerFactory.getLogger(ConditionalDelegateHandler.class);
+class ProxyInterceptor implements ProxyExchangeInterceptor {
+ private static final Logger LOG = LoggerFactory.getLogger(ProxyInterceptor.class);
- private List conditions;
- private List delegates;
+ private List conditions;
+ private List interceptors;
// If true, acts as AND. If false, acts as OR.
private boolean requireAllConditions = true;
+ ProxyInterceptor(
+ List conditions,
+ List interceptors,
+ boolean requireAllConditions) {
+ this.conditions = List.copyOf(conditions);
+ this.interceptors = List.copyOf(interceptors);
+ this.requireAllConditions = requireAllConditions;
+ }
+
/**
* Evaluates the configured conditions. If the criteria are met, the request
* is passed to all configured delegate handlers.
@@ -35,7 +41,7 @@ public class ConditionalDelegateHandler implements ProxyHttpServerExchangeHandle
*/
@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;
}
@@ -44,22 +50,10 @@ 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 (ProxyHttpServerExchangeHandler delegate : delegates) {
+ LOG.debug("Conditions met. Executing {} delegate handler(s) for {}", interceptors.size(), exchange.getRequestPath());
+ for (ProxyExchangeInterceptor delegate : interceptors) {
delegate.apply(exchange);
}
}
}
-
- public void setConditions(List conditions) {
- this.conditions = conditions;
- }
-
- public void setDelegates(List delegates) {
- this.delegates = delegates;
- }
-
- public void setRequireAllConditions(boolean requireAllConditions) {
- this.requireAllConditions = requireAllConditions;
- }
}
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
new file mode 100644
index 0000000..866c929
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/StaticResponseInterceptor.java
@@ -0,0 +1,36 @@
+package me.cxdev.commerce.proxy.interceptor;
+
+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).
+ */
+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) {
+ 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();
+ }
+}
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..8aed034
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/AndCondition.java
@@ -0,0 +1,26 @@
+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) {
+ assert conditions != null;
+ this.conditions = Arrays.asList(conditions);
+ }
+
+ @Override
+ public boolean matches(HttpServerExchange exchange) {
+ 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/interceptor/condition/CookieExistsCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/CookieExistsCondition.java
new file mode 100644
index 0000000..07b22ec
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/CookieExistsCondition.java
@@ -0,0 +1,27 @@
+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.
+ */
+class CookieExistsCondition implements ProxyExchangeInterceptorCondition {
+ private final String cookieName;
+
+ CookieExistsCondition(String cookieName) {
+ this.cookieName = cookieName;
+ }
+
+ @Override
+ public boolean matches(HttpServerExchange exchange) {
+ if (StringUtils.isBlank(cookieName)) {
+ return false;
+ }
+ return exchange.getRequestCookie(cookieName) != null;
+ }
+}
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/interceptor/condition/NotCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java
new file mode 100644
index 0000000..d453457
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/NotCondition.java
@@ -0,0 +1,22 @@
+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.
+ */
+class NotCondition implements ProxyExchangeInterceptorCondition {
+ private final ProxyExchangeInterceptorCondition condition;
+
+ NotCondition(ProxyExchangeInterceptorCondition condition) {
+ assert condition != null;
+ this.condition = condition;
+ }
+
+ @Override
+ public boolean matches(HttpServerExchange exchange) {
+ 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
new file mode 100644
index 0000000..ac75ece
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/OrCondition.java
@@ -0,0 +1,26 @@
+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.
+ */
+class OrCondition implements ProxyExchangeInterceptorCondition {
+ private final List conditions;
+
+ OrCondition(ProxyExchangeInterceptorCondition... conditions) {
+ assert conditions != null;
+ this.conditions = Arrays.asList(conditions);
+ }
+
+ @Override
+ public boolean matches(HttpServerExchange exchange) {
+ 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/PathAntMatcherCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathAntMatcherCondition.java
new file mode 100644
index 0000000..e54bd81
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathAntMatcherCondition.java
@@ -0,0 +1,32 @@
+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.
+ */
+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());
+ }
+}
diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathRegexCondition.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathRegexCondition.java
new file mode 100644
index 0000000..416670a
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/interceptor/condition/PathRegexCondition.java
@@ -0,0 +1,35 @@
+package me.cxdev.commerce.proxy.interceptor.condition;
+
+import java.util.regex.Pattern;
+
+import io.undertow.server.HttpServerExchange;
+
+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).
+ */
+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) {
+ if (compiledPattern == null) {
+ return false;
+ }
+ // Match the resolved path (e.g., "/occ/v2/electronics/users/current")
+ return compiledPattern.matcher(exchange.getRequestPath()).matches();
+ }
+}
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 59%
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..e8b56ee 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,24 +1,26 @@
-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)) {
- return false;
+ return true;
}
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..9a4f3b8
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/livecycle/GroovyRuleEngineService.java
@@ -0,0 +1,138 @@
+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 static final String INTERCEPTOR_BEAN_PREFIX = "cxdevproxyInterceptor";
+
+ 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()) {
+ 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);
+ 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.interceptor.Interceptors");
+ importCustomizer.addStaticStars("me.cxdev.commerce.proxy.interceptor.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(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..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
@@ -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.trust.AcceptAllTrustManager;
+import me.cxdev.commerce.proxy.handler.ProxyRouteHandler;
+import me.cxdev.commerce.proxy.interceptor.ProxyExchangeInterceptor;
+import me.cxdev.commerce.proxy.ssl.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,
@@ -113,22 +217,22 @@ 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);
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,16 +297,15 @@ 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 {
- 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);
@@ -213,32 +316,24 @@ private void routeRequest(HttpServerExchange exchange, List backendConte
}
/**
- * Wraps the base frontend handler with any configured custom interceptors/handlers.
+ * 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 -> {
- if (frontendHandlers != null) {
- frontendHandlers.forEach(handler -> 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.
- *
- * @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));
+ if (!exchange.isResponseStarted() && !exchange.isComplete()) {
+ next.handleRequest(exchange);
}
- next.handleRequest(exchange);
};
}
@@ -296,7 +391,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 +402,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 +422,7 @@ public int getPhase() {
return Integer.MAX_VALUE;
}
- // --- Setters for Spring Injection ---
+ // --- Standard Setters ---
public void setEnabled(boolean enabled) {
this.enabled = enabled;
@@ -367,6 +472,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 +492,37 @@ 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) {
+ 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 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/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;
- }
- }
-}
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/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..d997778
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/src/me/cxdev/commerce/proxy/util/TimeUtils.java
@@ -0,0 +1,59 @@
+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
+ }
+
+ 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 contextName A descriptive name for logging (e.g., "Groovy rule reload").
+ * @return The parsed interval in milliseconds.
+ */
+ public static long parseIntervalToMillis(String interval, String contextName) {
+ if (interval == null || interval.trim().isEmpty()) {
+ return 0L;
+ }
+
+ String trimmed = interval.trim().toLowerCase();
+ long multiplier = 1;
+ long value;
+
+ 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/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");
+ }
+}
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);
+ }
+}
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");
+ }
+}
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));
+ }
+}
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");
+ }
+ }
+}
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/InterceptorsTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/InterceptorsTest.java
new file mode 100644
index 0000000..a822529
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/InterceptorsTest.java
@@ -0,0 +1,170 @@
+package me.cxdev.commerce.proxy.interceptor;
+
+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;
+
+import jakarta.ws.rs.HttpMethod;
+
+@ExtendWith(MockitoExtension.class)
+class InterceptorsTest {
+ @Mock
+ private HttpServerExchange exchangeMock;
+
+ @Mock
+ private ProxyExchangeInterceptorCondition cond1;
+
+ @Mock
+ private ProxyExchangeInterceptorCondition cond2;
+
+ @Mock
+ private ProxyExchangeInterceptor delegate1;
+
+ @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
+ ProxyExchangeInterceptor emptyInterceptor = Interceptors.interceptor().perform();
+ emptyInterceptor.apply(exchangeMock);
+ verifyNoInteractions(exchangeMock);
+
+ // Test with conditions but no delegates
+ ProxyExchangeInterceptor noDelegates = Interceptors.interceptor()
+ .constrainedBy(cond1)
+ .perform();
+ noDelegates.apply(exchangeMock);
+ verifyNoInteractions(cond1, exchangeMock);
+
+ // Test null safety in builder
+ ProxyExchangeInterceptor nullSafety = Interceptors.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);
+
+ ProxyExchangeInterceptor interceptor = Interceptors.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);
+
+ ProxyExchangeInterceptor interceptor = Interceptors.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);
+
+ ProxyExchangeInterceptor interceptor = Interceptors.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);
+
+ ProxyExchangeInterceptor interceptor = Interceptors.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);
+
+ ProxyExchangeInterceptor interceptor = Interceptors.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);
+
+ ProxyExchangeInterceptor interceptor = Interceptors.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);
+ }
+}
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..bc84489
--- /dev/null
+++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/testsrc/me/cxdev/commerce/proxy/interceptor/JwtInjectorInterceptorTest.java
@@ -0,0 +1,119 @@
+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");
+ }
+}
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");
+ }
+}
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));
+ }
+}
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");
+ }
+}
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");
+ }
+}
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");
+ }
+}
diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevreporting/extensioninfo.xml
index ea5f96a..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 0e6bb88..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.0.0
+ 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 5b20292..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 923b854..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.0.0
+ 5.0.1
jar
diff --git a/sonar-project.properties b/sonar-project.properties
index bd02abf..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.0.0
+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