diff --git a/README.md b/README.md index 2a2118b..deaa5ed 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # CX DEV Workspace -[![Build & Test](https://github.com/cxdevtools/workspace/actions/workflows/buildandtest.yml/badge.svg)](https://github.com/cxdevtools/workspace/actions/workflows/buildandtest.yml) -[![Code Analysis](https://github.com/cxdevtools/workspace/actions/workflows/code-analysis.yml/badge.svg)](https://github.com/cxdevtools/workspace/actions/workflows/code-analysis.yml) -[![Code Coverage](https://codecov.io/gh/cxdevtools/workspace/branch/main/graph/badge.svg?token=F1BIK8R7NZ)](https://codecov.io/gh/cxdevtools/workspace) -[![Dependency Check](https://github.com/cxdevtools/workspace/actions/workflows/dependency-check.yml/badge.svg)](https://github.com/cxdevtools/workspace/actions/workflows/dependency-check.yml) +[![Build & Test](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/buildandtest.yml/badge.svg)](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/buildandtest.yml) +[![Code Analysis](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/code-analysis.yml/badge.svg)](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/code-analysis.yml) +[![Code Coverage](https://codecov.io/gh/cxdevtools/sap-commerce-cloud/branch/main/graph/badge.svg?token=F1BIK8R7NZ)](https://codecov.io/gh/cxdevtools/sap-commerce-cloud) +[![Dependency Check](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/dependency-check.yml/badge.svg)](https://github.com/cxdevtools/sap-commerce-cloud/actions/workflows/dependency-check.yml) All extensions available in this repository are built with high test coverage and do not influence the behavior of your project without changes to your configuration. This is guaranteed and intended by design, because the extensions @@ -14,8 +14,8 @@ configuration parameter. ## Contributing Contributions are both welcomed and appreciated. For specific guidelines regarding contributions, please see -[CONTRIBUTING.md](https://github.com/cxdevtools/workspace/blob/main/CONTRIBUTING.md) in the root directory of the +[CONTRIBUTING.md](https://github.com/cxdevtools/sap-commerce-cloud/blob/main/CONTRIBUTING.md) in the root directory of the project. Those willing to use milestone or SNAPSHOT releases are encouraged to file feature requests and bug reports -using the project's [issue tracker](https://github.com/cxdevtools/workspace/issues). Issues marked with an -`help wanted` label are specifically +using the project's [issue tracker](https://github.com/cxdevtools/sap-commerce-cloud/issues). Issues marked with an +`help wanted` label are specifically targeted for community contributions. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml index e12900c..2a35569 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/extensioninfo.xml @@ -1,6 +1,6 @@ - + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml index e203eb3..b86b779 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevbackoffice/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevbackoffice - 5.0.0 + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml index 5b4fe64..116068c 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/extensioninfo.xml @@ -1,7 +1,7 @@ + name="cxdevenvconfig" version="5.0.1" usemaven="true"> diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml index 11d6e5e..e3c9db1 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevenvconfig/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevenvconfig - 5.0.0 + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md index 572289d..98e11a6 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/CONTRIBUTING.md @@ -1,4 +1,4 @@ This repository has a special setup for contributing. -Please read the [CONTRIBUTING.md from the extensions repository](https://github.com/sapcxtools/workspace/blob/main/CONTRIBUTING.md) which +Please read the [CONTRIBUTING.md from the extensions repository](https://github.com/cxdevtools/sap-commerce-cloud/blob/main/CONTRIBUTING.md) which will guide you through the process. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md index c5c4722..ac2f307 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/README.md @@ -1,100 +1,182 @@ # CX Dev Proxy -The `cxdevproxy` extension improves and simplifies the local development experience for SAP Commerce by providing an -embedded, highly configurable Undertow reverse proxy. +The **CX Dev Proxy** is a powerful, Undertow-based local development proxy extension for SAP Commerce. It acts as a transparent reverse proxy in front of your local SAP Commerce Tomcat instance, solving the most common frontend and headless development pain points out-of-the-box. -## FEATURE DESCRIPTION -This extension solves common local routing and authentication challenges when developing headless storefronts (like -Spartacus/Angular) against a local SAP Commerce backend. It provides a seamless "all-in-one" endpoint for developers. -In addition, it contributes several major features to the local development lifecycle. +## 🚀 Key Features -### Embedded Reverse Proxy & Header Forwarding +* **Dynamic Groovy DSL & Hot-Reloading:** Define routing rules and HTTP modifications using a highly readable, fluent Groovy DSL. Save the script and the proxy updates instantly with **zero downtime**—no server restarts required! +* **Clean Interceptor Architecture:** A strict separation between Request Routing (Conditions) and Request Modification (Interceptors) ensures predictable, thread-safe request manipulation. +* **Zero-Config JWT Mocking:** Automatically injects valid JWT tokens for local development. It uses the platform's native `jwkSource`, meaning tokens are fully trusted by SAP Commerce without any backend configuration. +* **Developer Auth Portal:** An out-of-the-box, bilingual UI portal (`/proxy/login.html`, supporting English, German, and more via `Accept-Language`) to easily switch between mock Employee, B2C, and B2B user contexts. +* **Auto-CORS:** Automatically handles Cross-Origin Resource Sharing (CORS) preflight requests, echoing the incoming Origin header. Perfect for local Angular/React/Vue apps running on different ports (e.g., `localhost:4200`). +* **Conflict-Free Configuration:** Securely injects Spring backend properties and i18n messages into frontend templates using custom `%` and `#` syntax, eliminating collisions with modern JavaScript. -A lightweight Undertow server that listens on a unified port (e.g., `8080`) and dynamically routes traffic to either the -local frontend dev server (e.g., Angular on `4200`) or the SAP Commerce backend (Tomcat on `9002`). -The `ForwardedHeadersHandler` automatically injects `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` -headers. This prevents infinite HTTPS redirect loops from Spring Security and ensures that Tomcat generates absolute -URLs correctly. +--- -### Developer Portal & JWT Mocking +## 🛠 Configuration & Properties -Provides a local Developer Portal (accessible via the root or `index.html`) to easily switch between different mocked -user sessions (Employees or Customers). -When a user is selected, the `JwtInjectorHandler` intercepts backend requests, dynamically generates a signed JWT using -the local domain's private key (via Nimbus JOSE+JWT), and injects it as a `Bearer` token. The static JWT claims can be -easily managed via JSON templates. +You can configure the core behavior via your `local.properties` (or `project.properties`). -### Startup Interception +```properties +# ----------------------------------------------------------------------- +# CX Dev Proxy - Core Configuration +# ----------------------------------------------------------------------- +# Enables or disables the proxy +cxdevproxy.enabled=true -The `StartupPageHandler` listens to the Hybris tenant lifecycle. While the master tenant is starting up or shutting -down, all incoming proxy requests are intercepted, and a localized, auto-refreshing "503 Service Unavailable" -maintenance page is served to prevent hanging requests or backend errors. +# Port on which the proxy will listen +cxdevproxy.server.port=8080 -### Modular Static Content +# --- Dynamic Routing Rules (Groovy DSL) --- +# Paths to the Groovy scripts defining the proxy rules. +# Supports 'classpath:' (inside exploded extensions) and 'file:' (absolute path on disk). +cxdevproxy.proxy.frontend.rules=classpath:cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy +cxdevproxy.proxy.backend.rules=classpath:cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy -The `StaticContentHandler` allows serving static files (HTML, CSS, JS, images) directly from the classpath without -invoking the backend server. Files placed in `resources/cxdevproxy/static-content/` are automatically served. +# --- UI & Auth Portal Configuration --- +# Toggle visibility of customer tabs in the /proxy/login.html portal +cxdevproxy.proxy.ui.login.showB2C=false +cxdevproxy.proxy.ui.login.showB2B=true -### Extensible Conditional Handlers +# --- JWT Mocking Configuration --- +# Specifies the base path where the proxy looks for JWT claim templates (JSON files). +cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt -The proxy pipeline is highly customizable. The `ConditionalDelegateHandler` allows executing specific handlers only if a -set of conditions is met (e.g., matching HTTP methods, specific paths, or headers). It supports complex logical -expressions via `AndCondition`, `OrCondition`, and `NotCondition`. +# Defines the validity duration of the generated mock JWT tokens. +# Supported formats: 500ms, 60s, 10m, 10h, 1d (Defaults to ms if no unit is provided) +cxdevproxy.proxy.jwt.validity=10h +``` -## How to activate and use +--- -To activate this feature, simply set the `cxdevproxy.enabled` property to `true` in your `local.properties`. +## 🖥 Developer Auth Portal & Safe Properties -**Adding Static Content:** -Other extensions can contribute to the proxy's static files (like adding new pages to the Developer Portal) by simply -placing files inside their own `resources/cxdevproxy/static-content/` directory. The proxy classloader will pick them up -automatically. +The extension provides a built-in UI to set mock user cookies. You can access it via `/proxy/login.html`. -**Adding new Mock Users (JWT Templates):** -To add a new mock user, create a JSON file with the static claims in `resources/cxdevproxy/jwt/employee/.json` or -`resources/cxdevproxy/jwt/customer/.json`. +To prevent syntax collisions between Spring property resolution and modern JavaScript template literals (`${...}`), the HTML templates utilize a custom, robust placeholder syntax: +* **`%{property.key:defaultValue}`** for Spring Configuration Properties +* **`#{i18n.message.key:Default Text}`** for localized Message Bundles -**Adding Custom Handlers and Conditions:** -You can extend the proxy routing by defining new conditions and handlers in your Spring configuration. Simply create -your custom conditions and inject them into a `ConditionalDelegateHandler` via Spring XML: +This allows you to safely toggle UI elements based on your backend configuration without breaking frontend scripts: -```xml - - - +```javascript +// Safely resolved by the proxy's TemplateRenderingHandler before reaching the browser +const showB2C = %{cxdevproxy.proxy.ui.login.showB2C:false}; +const showB2B = %{cxdevproxy.proxy.ui.login.showB2B:false}; ``` -Then add your handler to the `backendHandlers` list of the `UndertowProxyManager` bean. +--- + +## 🧩 Building Routing Rules (The Groovy DSL) + +Instead of verbose XML, the CX Dev Proxy uses a powerful Groovy Domain Specific Language (DSL). The scripts are hot-reloaded the moment you save them. + + + +To provide the best Developer Experience, our rule engine **automatically imports** all fluent condition factories (`Conditions.*`) and interceptor builders (`Interceptors.*`), and dynamically binds existing Spring beans to the script context. + +### 1. Fluent API (Conditions & Interceptors) + +You can build complex routes using our functional, AssertJ-style API directly in the script: + +**Conditions:** +* `isMethod("GET")`, `pathStartsWith("/occ")`, `pathMatches("/occ/v2/**")`, `pathRegexMatches(".*")` +* `hasHeader("Authorization")`, `hasCookie("cxdevproxy_user_id")`, `hasParameter("username")` +* **Logical Operators:** `and(...)`, `or(...)`, `not(...)`, `always()`, `never()` + +**Inline Interceptors:** +* `jsonResponse(200, '{"status":"ok"}')` +* `htmlResponse("

Hello

")` +* `networkDelay("800ms")` or `networkDelay("1s", "3s")` + +### 2. Pre-configured Spring Variables & Magic Naming + +The script environment is automatically populated with context-aware variables (derived from your Spring XML) to make routing effortless. + +**Custom Bean Naming Convention:** +If you write your own Spring beans for Conditions or Interceptors, follow this prefix convention. The proxy engine will automatically strip the prefix and bind the bean using lower-camel-case: +* Bean `cxdevproxyConditionIsOcc` becomes `isOcc` in Groovy. +* Bean `cxdevproxyInterceptorCxJwtInjector` becomes `cxJwtInjector` in Groovy. + +**Available Pre-bound Variables:** +* **Conditions:** `isOcc`, `isSmartEdit`, `isBackoffice`, `isAdminConsole`, `isAuthorizationServer`, `hasMockUser`, `hasAuthorizationHeader` +* **Interceptors:** `forwardedHeaders`, `jwtInjector`, `corsInjector` + +--- + +## 💡 Usage Examples +To add custom rules for your project, first override the default rule paths in your `local.properties` to point to your custom project directory: -## Configuration parameters +```properties +cxdevproxy.proxy.backend.rules=classpath:path/to/your/project/my-backend-rules.groovy +cxdevproxy.proxy.frontend.rules=classpath:path/to/your/project/my-frontend-rules.groovy +``` + +Every script must return a `List`. Rules are evaluated top-to-bottom. + +### 1. The Baseline (Default Script) + +The most basic setup simply applies standard proxy headers unconditionally: + +```groovy +return [ + forwardedHeaders +] +``` + +### 2. Conditional Execution (The Builder Pattern) + +You should never mutate the state of injected Spring beans directly. Instead, wrap them using the stateless `interceptor()` builder to apply them conditionally. + +```groovy +// 1. Combine pre-configured conditions seamlessly using static logical operators +def jwtCondition = and(or(isOcc, isSmartEdit), hasMockUser, not(hasAuthorizationHeader)) + +// 2. Simple API Mocking with inline JSON +def mockCartCall = interceptor() + .constrainedBy( isMethod("GET"), pathMatches("/**/carts/current") ) + .perform( jsonResponse('{"type": "cartWsDTO", "totalItems": 5}') ) + +// 3. Simulating Latency +def slowCheckout = interceptor() + .constrainedBy( isMethod("POST"), pathMatches("/**/orders") ) + .perform( networkDelay("1s", "3s") ) + +return [ + forwardedHeaders, // Always execute + + // Execute JWT Injector only if the complex condition is met + interceptor() + .constrainedBy(jwtCondition) + .perform(jwtInjector), + + // Execute CORS Injector only for OCC requests + interceptor() + .constrainedBy(isOcc) + .perform(corsInjector), + + mockCartCall, + slowCheckout +] +``` -| Parameter | Type | Description | -|-----------|------|-------------| -| cxdevproxy.enabled | boolean | Feature toggle. Must be set to `true` to start the Undertow proxy server (default: false). | -| cxdevproxy.application-context | String | Specifies the location of the spring context file automatically added to the global platform application context. | -| cxdevproxy.ssl.enabled | boolean | Enables HTTPS for the Undertow server. If false, forces HTTP routing. | -| cxdevproxy.ssl.keystore.path | String | Absolute path to the PKCS12 keystore used for SSL offloading and JWT signing. | -| cxdevproxy.ssl.keystore.password | String | Password for the configured keystore. | -| cxdevproxy.ssl.keystore.alias | String | Alias of the private key within the keystore used for SSL and JWT signing. | -| cxdevproxy.server.bindaddress | String | Network interface the proxy listens on (e.g., `127.0.0.1` or `0.0.0.0`). | -| cxdevproxy.server.protocol | String | The protocol exposed by the proxy (`http` or `https`). | -| cxdevproxy.server.hostname | String | The public hostname of the proxy (e.g., `local.cxdev.me`). Used for the `X-Forwarded-Host` header. | -| cxdevproxy.server.port | int | The port the Undertow proxy listens on (default: `8080`). | -| cxdevproxy.proxy.frontend.protocol | String | Protocol for frontend routing (e.g., `http` or `https`). | -| cxdevproxy.proxy.frontend.hostname | String | Hostname of the local frontend dev server (e.g., `localhost`). | -| cxdevproxy.proxy.frontend.port | int | Port of the local frontend dev server (e.g., `4200`). | -| cxdevproxy.proxy.backend.protocol | String | Protocol for backend routing (e.g., `https`). | -| cxdevproxy.proxy.backend.hostname | String | Hostname of the local SAP Commerce backend (e.g., `localhost`). | -| cxdevproxy.proxy.backend.port | int | Port of the local SAP Commerce backend (e.g., `9002`). | -| cxdevproxy.proxy.backend.contexts | String | Comma-separated list of paths to route to the backend. If empty, uses auto-discovery via webroot properties. | +--- +## 🔑 JWT Mocking Deep Dive -## License +The `jwtInjector` is the heart of the local authentication bypass. It intercepts requests (when conditions match) and injects a dynamically signed JWT. -_Licensed under the Apache License, Version 2.0, January 2004_ +> **âš ï¸ IMPORTANT: OAuth Client ID Requirement** +> By default, our provided B2C and B2B user templates use `storefront` as the `client_id`. This breaks with the SAP Commerce default (which uses `mobile_android` for OCC). +> To make the mock tokens work, you must ensure an OAuth Client with the ID `storefront` is created in your local SAP Commerce database (via ImpEx). +> We understand that this might be questionable, but we are convinced that using a self-explaining `client_id` is a best-practise and should be enforced anyway. If you don't agree, feel free to change the templates to your needs. -_Copyright 2026, SAP CX Tools_ +1. **Native Trust:** It extracts the private key from the platform's `jwkSource` (the exact same one used by the `authorizationserver`). This means the backend trusts the generated tokens implicitly. No extra backend configuration needed! +2. **Templates:** It loads static claims from JSON files. For example, if the cookies are `user_type=customer` and `user_id=john@example.com`, it looks for a template at: + `classpath:cxdevproxy/jwt/customer/john@example.com.json` +3. **Dynamic Claims:** Claims like `iat` (Issued At) and `exp` (Expiration) are dynamically calculated based on `cxdevproxy.proxy.jwt.validity` and automatically cache-managed to prevent mid-flight expirations. +To customize templates per project, simply set `cxdevproxy.proxy.jwt.templatepath=path/to/your/custom/templates` in your local properties. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml index d392fa6..f663b49 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/extensioninfo.xml @@ -1,6 +1,7 @@ - + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml index 5513ac1..82f7db5 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml @@ -3,7 +3,7 @@ 4.0.0 me.cxdev cxdevproxy - 5.0.0 + 5.0.1 jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties index 693e16b..58b6d81 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/project.properties @@ -4,13 +4,12 @@ cxdevproxy.application-context=cxdevproxy-spring.xml # ----------------------------------------------------------------------- # CXDEVPROXY CONFIGURATION # ----------------------------------------------------------------------- - # Feature Toggle: Must be explicitly set to 'true' to start the Undertow proxy server. cxdevproxy.enabled=false # SSL / Keystore Configuration # Defines the keystore details used for securing the proxy server via HTTPS. -cxdevproxy.ssl.enabled=true +cxdevproxy.ssl.enabled=false cxdevproxy.ssl.keystore.path=${HYBRIS_CONFIG_DIR}/../../../certificates/local.cxdev.me.p12 cxdevproxy.ssl.keystore.password=123456 cxdevproxy.ssl.keystore.alias=local.cxdev.me @@ -18,23 +17,61 @@ cxdevproxy.ssl.keystore.alias=local.cxdev.me # Undertow Server Binding # Defines the network interface, hostname, and port the embedded proxy will listen on. cxdevproxy.server.bindaddress=127.0.0.1 -cxdevproxy.server.protocol=https +cxdevproxy.server.protocol=http cxdevproxy.server.hostname=local.cxdev.me cxdevproxy.server.port=8080 -# Frontend Routing (Target) +# Rule Configuration reloading +cxdevproxy.proxy.rules.reloadinterval=5s + +# ----------------------------------------------------------------------- +# CX Dev Proxy - Static Files (Target) +# ----------------------------------------------------------------------- +cxdevproxy.proxy.ui.login.showB2C=true +cxdevproxy.proxy.ui.login.showB2B=true +cxdevproxy.proxy.ui.baselocation=cxdevproxy/ui +cxdevproxy.proxy.ui.messages.basename=cxdevproxy/i18n/messages +cxdevproxy.proxy.ui.messages.reloadinterval=5s +cxdevproxy.proxy.ui.messages.codeasfallback=true + +# ----------------------------------------------------------------------- +# CX Dev Proxy - Frontend Routing (Target) +# ----------------------------------------------------------------------- # Target configuration for routing frontend requests (e.g., local Angular dev server). cxdevproxy.proxy.frontend.protocol=https cxdevproxy.proxy.frontend.hostname=localhost cxdevproxy.proxy.frontend.port=4200 -# Backend Routing (Target) +# Path to the Groovy script defining the frontend proxy rules. +cxdevproxy.proxy.frontend.rules=cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy + +# ----------------------------------------------------------------------- +# CX Dev Proxy - Backend Routing (Target) +# ----------------------------------------------------------------------- # Target configuration for routing SAP Commerce backend requests (local Tomcat). cxdevproxy.proxy.backend.protocol=https cxdevproxy.proxy.backend.hostname=localhost cxdevproxy.proxy.backend.port=9002 +# Path to the Groovy script defining the backend proxy rules. +cxdevproxy.proxy.backend.rules=cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy + # Backend Contexts (Comma-separated) # Explicit list of URL paths to be routed to the backend. # If left empty, auto-discovery will automatically determine backend routes via the .webroot properties. -cxdevproxy.proxy.backend.contexts= \ No newline at end of file +cxdevproxy.proxy.backend.contexts= + +# ----------------------------------------------------------------------- +# CX Dev Proxy - JWT Mocking Configuration +# ----------------------------------------------------------------------- +# Specifies the base path where the proxy looks for JWT claim templates (JSON files). +# The final path is resolved as: //.json +# Supports Spring ResourceLoader prefixes such as 'classpath:' or 'file:'. +# Example for absolute local path: file:/opt/hybris/config/jwt-templates +cxdevproxy.proxy.jwt.templatepath=classpath:cxdevproxy/jwt + +# Defines the validity duration of the generated mock JWT tokens. +# Supports smart time units: 's' (seconds), 'm' (minutes), 'h' (hours), 'd' (days). +# If no unit is provided, it defaults to milliseconds. +# Examples: 3600s, 60m, 10h, 1d +cxdevproxy.proxy.jwt.validity=10h \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml index ca82057..2abc298 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy-spring.xml @@ -21,35 +21,48 @@ + + - - - + + + + + - - - + + + + + + + + + + + + + + + + + + - - - - - + + + + - - - - - - - + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml similarity index 57% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml index 7bec4c1..b22c9bf 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-jwt-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-additions-spring.xml @@ -6,10 +6,6 @@ http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"> - - - - - - + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml new file mode 100644 index 0000000..a05c55c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-conditions-spring.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml index c652e28..842ceaf 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-handler-spring.xml @@ -1,55 +1,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml new file mode 100644 index 0000000..9b03cf7 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/config/cxdevproxy-interceptor-spring.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties new file mode 100644 index 0000000..b84e8f4 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_de.properties @@ -0,0 +1,17 @@ +startup.page.title=Server startet... +startup.page.message=SAP Commerce ist am Starten. Bitte warten, diese Seite aktualisiert sich automatisch. +page.title=CX Dev Proxy - Mock Login +heading.main=Lokales Auth-Mocking +text.description=Wähle einen Mock-Benutzer aus, um automatisch gültige JWT-Tokens in deine lokalen Storefront-Anfragen zu injizieren. +tab.b2c=B2C Kunden +tab.b2b=B2B Kunden +label.select.user=Benutzerprofil wählen: +user.anonymous=B2C Besucher (visitor@cxdev.me) +user.customer=B2C Kunde (customer@cxdev.me) +user.b2b.customer=B2B Kunde (b2bcustomer@cxdev.me) +user.b2b.approver=B2B Freigeber (b2bapprover@cxdev.me) +user.b2b.manager=B2B Manager (b2bmanager@cxdev.me) +btn.login=Mock-User setzen +btn.logout=Cookies löschen +msg.success=Mock-Cookies erfolgreich gesetzt für: +msg.cleared=Mock-Cookies entfernt. Du surfst nun als normaler Gast. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties new file mode 100644 index 0000000..1dae34a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_en.properties @@ -0,0 +1,17 @@ +startup.page.title=Starting up... +startup.page.message=SAP Commerce is currently starting. Please wait, this page will refresh automatically. +page.title=CX Dev Proxy - Mock Login +heading.main=Local Auth Mocking +text.description=Select a mock user to automatically inject valid JWT tokens into your local storefront requests. +tab.b2c=B2C Customers +tab.b2b=B2B Customers +label.select.user=Select User Profile: +user.anonymous=B2C Visitor (visitor@cxdev.me) +user.customer=B2C Customer (customer@cxdev.me) +user.b2b.customer=B2B Customer (b2bcustomer@cxdev.me) +user.b2b.approver=B2B Approver (b2bapprover@cxdev.me) +user.b2b.manager=B2B Unit Manager (b2bmanager@cxdev.me) +btn.login=Set Mock User +btn.logout=Clear Cookies +msg.success=Mock cookies successfully set for: +msg.cleared=Mock cookies removed. You are now browsing as a standard guest. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties new file mode 100644 index 0000000..c7934ac --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_es.properties @@ -0,0 +1,17 @@ +startup.page.title=Iniciando... +startup.page.message=SAP Commerce se está iniciando. Por favor espere, esta página se actualizará automáticamente. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking de Autenticación Local +text.description=Seleccione un usuario mock para inyectar automáticamente tokens JWT válidos en sus peticiones locales del storefront. +tab.b2c=Clientes B2C +tab.b2b=Clientes B2B +label.select.user=Seleccionar perfil de usuario: +user.anonymous=Visitante B2C (visitor@cxdev.me) +user.customer=Cliente B2C (customer@cxdev.me) +user.b2b.customer=Cliente B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Aprobador B2B (b2bapprover@cxdev.me) +user.b2b.manager=Gestor de Unidad B2B (b2bmanager@cxdev.me) +btn.login=Establecer Usuario Mock +btn.logout=Borrar Cookies +msg.success=Cookies mock establecidas correctamente para: +msg.cleared=Cookies mock eliminadas. Ahora está navegando como invitado estándar. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties new file mode 100644 index 0000000..054eec9 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_fr.properties @@ -0,0 +1,17 @@ +startup.page.title=Démarrage en cours... +startup.page.message=SAP Commerce est en cours de démarrage. Veuillez patienter, cette page s'actualisera automatiquement. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking d'Authentification Locale +text.description=Sélectionnez un utilisateur mock pour injecter automatiquement des jetons JWT valides dans vos requêtes locales du storefront. +tab.b2c=Clients B2C +tab.b2b=Clients B2B +label.select.user=Sélectionner le profil utilisateur : +user.anonymous=Visiteur B2C (visitor@cxdev.me) +user.customer=Client B2C (customer@cxdev.me) +user.b2b.customer=Client B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Approbateur B2B (b2bapprover@cxdev.me) +user.b2b.manager=Manager d'Unité B2B (b2bmanager@cxdev.me) +btn.login=Définir l'utilisateur Mock +btn.logout=Effacer les Cookies +msg.success=Cookies mock définis avec succès pour : +msg.cleared=Cookies mock supprimés. Vous naviguez maintenant en tant qu'invité standard. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties new file mode 100644 index 0000000..3b250b2 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_it.properties @@ -0,0 +1,17 @@ +startup.page.title=Avvio in corso... +startup.page.message=SAP Commerce è in fase di avvio. Attendere, questa pagina si aggiornerà automaticamente. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking dell'Autenticazione Locale +text.description=Seleziona un utente mock per iniettare automaticamente token JWT validi nelle tue richieste locali allo storefront. +tab.b2c=Clienti B2C +tab.b2b=Clienti B2B +label.select.user=Seleziona profilo utente: +user.anonymous=Visitatore B2C (visitor@cxdev.me) +user.customer=Cliente B2C (customer@cxdev.me) +user.b2b.customer=Cliente B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Approvatore B2B (b2bapprover@cxdev.me) +user.b2b.manager=Manager di Unità B2B (b2bmanager@cxdev.me) +btn.login=Imposta Utente Mock +btn.logout=Cancella Cookie +msg.success=Cookie mock impostati con successo per: +msg.cleared=Cookie mock rimossi. Ora stai navigando come ospite standard. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties new file mode 100644 index 0000000..0af9029 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ja.properties @@ -0,0 +1,17 @@ +startup.page.title=???... +startup.page.message=SAP Commerce???????????????????????????????????????? +page.title=CX Dev Proxy - ??????? +heading.main=????????? +text.description=??????????????????????????????????JWT??????????????? +tab.b2c=B2C ?? +tab.b2b=B2B ?? +label.select.user=?????????????: +user.anonymous=B2C ??? (visitor@cxdev.me) +user.customer=B2C ?? (customer@cxdev.me) +user.b2b.customer=B2B ?? (b2bcustomer@cxdev.me) +user.b2b.approver=B2B ??? (b2bapprover@cxdev.me) +user.b2b.manager=B2B ?????????? (b2bmanager@cxdev.me) +btn.login=?????????? +btn.logout=Cookie???? +msg.success=?????Cookie???????????: +msg.cleared=???Cookie?????????????????????????????? \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties new file mode 100644 index 0000000..f97076e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_no.properties @@ -0,0 +1,17 @@ +startup.page.title=Starter opp... +startup.page.message=SAP Commerce starter for øyeblikket. Vennligst vent, denne siden vil oppdateres automatisk. +page.title=CX Dev Proxy - Mock Login +heading.main=Lokal Autentiseringsmocking +text.description=Velg en mock-bruker for å automatisk injisere gyldige JWT-tokens i dine lokale storefront-forespørsler. +tab.b2c=B2C Kunder +tab.b2b=B2B Kunder +label.select.user=Velg brukerprofil: +user.anonymous=B2C Besøkende (visitor@cxdev.me) +user.customer=B2C Kunde (customer@cxdev.me) +user.b2b.customer=B2B Kunde (b2bcustomer@cxdev.me) +user.b2b.approver=B2B Godkjenner (b2bapprover@cxdev.me) +user.b2b.manager=B2B Enhetsleder (b2bmanager@cxdev.me) +btn.login=Sett Mock-bruker +btn.logout=Tøm Informasjonskapsler +msg.success=Mock-informasjonskapsler er satt for: +msg.cleared=Mock-informasjonskapsler fjernet. Du surfer nå som en standard gjest. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties new file mode 100644 index 0000000..bf0506a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_pt.properties @@ -0,0 +1,17 @@ +startup.page.title=Iniciando... +startup.page.message=O SAP Commerce está iniciando. Por favor, aguarde, esta página será atualizada automaticamente. +page.title=CX Dev Proxy - Mock Login +heading.main=Mocking de Autenticação Local +text.description=Selecione um usuário mock para injetar automaticamente tokens JWT válidos em suas requisições locais do storefront. +tab.b2c=Clientes B2C +tab.b2b=Clientes B2B +label.select.user=Selecionar Perfil de Usuário: +user.anonymous=Visitante B2C (visitor@cxdev.me) +user.customer=Cliente B2C (customer@cxdev.me) +user.b2b.customer=Cliente B2B (b2bcustomer@cxdev.me) +user.b2b.approver=Aprovador B2B (b2bapprover@cxdev.me) +user.b2b.manager=Gerente de Unidade B2B (b2bmanager@cxdev.me) +btn.login=Definir Usuário Mock +btn.logout=Limpar Cookies +msg.success=Cookies mock definidos com sucesso para: +msg.cleared=Cookies mock removidos. Você agora está navegando como um convidado padrão. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties new file mode 100644 index 0000000..90884d5 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_ru.properties @@ -0,0 +1,17 @@ +startup.page.title=??????... +startup.page.message=SAP Commerce ? ?????? ?????? ???????????. ??????????, ?????????, ??? ???????? ????????? ?????????????. +page.title=CX Dev Proxy - Mock Login +heading.main=??????????? ????????? ?????????????? +text.description=???????? mock-???????????? ??? ??????????????? ????????? ???????? JWT ??????? ? ???? ????????? ??????? ? storefront. +tab.b2c=B2C ??????? +tab.b2b=B2B ??????? +label.select.user=???????? ??????? ????????????: +user.anonymous=B2C ?????????? (visitor@cxdev.me) +user.customer=B2C ?????? (customer@cxdev.me) +user.b2b.customer=B2B ?????? (b2bcustomer@cxdev.me) +user.b2b.approver=B2B ???????????? (b2bapprover@cxdev.me) +user.b2b.manager=B2B ???????? ????????????? (b2bmanager@cxdev.me) +btn.login=?????????? Mock-???????????? +btn.logout=???????? Cookies +msg.success=Mock cookies ??????? ??????????? ???: +msg.cleared=Mock cookies ???????. ?????? ?? ?????????????? ???? ??? ??????????? ?????. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties new file mode 100644 index 0000000..ac6a64f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_sv.properties @@ -0,0 +1,17 @@ +startup.page.title=Startar upp... +startup.page.message=SAP Commerce startar för närvarande. Vänligen vänta, den här sidan kommer att uppdateras automatiskt. +page.title=CX Dev Proxy - Mock Login +heading.main=Lokal Autentiseringsmocking +text.description=Välj en mock-användare för att automatiskt injicera giltiga JWT-tokens i dina lokala storefront-förfrågningar. +tab.b2c=B2C-kunder +tab.b2b=B2B-kunder +label.select.user=Välj användarprofil: +user.anonymous=B2C-besökare (visitor@cxdev.me) +user.customer=B2C-kund (customer@cxdev.me) +user.b2b.customer=B2B-kund (b2bcustomer@cxdev.me) +user.b2b.approver=B2B-godkännare (b2bapprover@cxdev.me) +user.b2b.manager=B2B-enhetschef (b2bmanager@cxdev.me) +btn.login=Ställ in Mock-användare +btn.logout=Rensa Cookies +msg.success=Mock-cookies har angetts för: +msg.cleared=Mock-cookies borttagna. Du surfar nu som en standardgäst. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties new file mode 100644 index 0000000..78437e4 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/i18n/messages_zh.properties @@ -0,0 +1,17 @@ +startup.page.title=????... +startup.page.message=SAP Commerce ???????????????????? +page.title=CX Dev Proxy - Mock ?? +heading.main=?????? Mock +text.description=???? mock ?????????? JWT ????????? storefront ???? +tab.b2c=B2C ?? +tab.b2b=B2B ?? +label.select.user=????????? +user.anonymous=B2C ?? (visitor@cxdev.me) +user.customer=B2C ?? (customer@cxdev.me) +user.b2b.customer=B2B ?? (b2bcustomer@cxdev.me) +user.b2b.approver=B2B ??? (b2bapprover@cxdev.me) +user.b2b.manager=B2B ???? (b2bmanager@cxdev.me) +btn.login=?? Mock ?? +btn.logout=?? Cookies +msg.success=?????????? Mock cookies? +msg.cleared=Mock cookies ??????????????????? \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json new file mode 100644 index 0000000..6b2d437 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bapprover@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "b2bapprover@cxdev.me", + "name": "B2B Approver", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_B2BCUSTOMERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json new file mode 100644 index 0000000..7f0020c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bcustomer@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "b2bcustomer@cxdev.me", + "name": "B2B Customer", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_B2BCUSTOMERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json new file mode 100644 index 0000000..cbedffa --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/b2bmanager@cxdev.me.json @@ -0,0 +1,12 @@ +{ + "sub": "b2bmanager@cxdev.me", + "name": "B2B Manager", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_B2BCUSTOMERGROUP", + "ROLE_B2BCUSTOMERMANAGERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json deleted file mode 100644 index c6d8cfc..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besteller.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "besteller", - "name": "Moritz Besteller", - "authorities": ["ROLE_B2BCUSTOMERGROUP", "ROLE_B2BCUSTOMERADMINGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json deleted file mode 100644 index b02daaa..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/besucher.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "besucher", - "name": "Max Besucher", - "authorities": ["ROLE_B2BCUSTOMERGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json deleted file mode 100644 index 56de521..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "customer@cxdev.me", - "name": "Default Customer", - "authorities": ["ROLE_B2BCUSTOMERGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json new file mode 100644 index 0000000..c3bd050 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/customer@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "customer@cxdev.me", + "name": "B2C Customer", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_CUSTOMERGROUP" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json deleted file mode 100644 index 5976966..0000000 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/training.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sub": "besucher", - "name": "Franz Training", - "authorities": ["ROLE_B2BCUSTOMERGROUP", "ROLE_TRAININGGROUP"] -} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json new file mode 100644 index 0000000..0d2ea61 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/customer/visitor@cxdev.me.json @@ -0,0 +1,11 @@ +{ + "sub": "visitor@cxdev.me", + "name": "B2C Visitor", + "client_id": "storefront", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_ANONYMOUS" + ] +} \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json index 8d8be54..d69a584 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/admin.json @@ -1,5 +1,12 @@ { "sub": "admin", - "name": "Shop Admin", - "authorities": ["ROLE_ADMINGROUP", "ROLE_EMPLOYEEGROUP"] + "name": "Admin", + "aud": "backoffice", + "client_id": "backoffice", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_ADMINGROUP" + ] } \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json index 2a2608e..42a75c8 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/cmsmanager.json @@ -1,5 +1,13 @@ { "sub": "cmsmanager", "name": "CMS Manager", - "authorities": ["ROLE_CMSMANAGERGROUP", "ROLE_EMPLOYEEGROUP"] + "aud": "smartedit", + "client_id": "smartedit", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_ADMINGROUP", + "ROLE_CMSMANAGERGROUP" + ] } \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json index a30fe49..84b0c97 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/jwt/employee/productmanager.json @@ -1,5 +1,12 @@ { "sub": "productmanager", "name": "Product Manager", - "authorities": ["ROLE_PRODUCTMANAGERGROUP", "ROLE_EMPLOYEEGROUP"] + "aud": "backoffice", + "client_id": "backoffice", + "scope": [ + "basic" + ], + "authorities": [ + "ROLE_PRODUCTMANAGERGROUP" + ] } \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy new file mode 100644 index 0000000..8235f6c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-backend-rules.groovy @@ -0,0 +1,5 @@ +return [ + interceptor() + .constrainedBy( isMethod("GET"), pathMatches("/**/carts/current") ) + .perform( jsonResponse('{"type": "cartWsDTO", "totalItems": 5}') ) +] \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy new file mode 100644 index 0000000..43e25a7 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/rulesets/cxdevproxy-frontend-rules.groovy @@ -0,0 +1,3 @@ +def interceptor = [] +interceptor << forwardedHeaders +return interceptor \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/favicon.ico b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/favicon.ico deleted file mode 100644 index 9bcfd4b..0000000 Binary files a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/favicon.ico and /dev/null differ diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/index.html b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/ui/proxy/login.html similarity index 50% rename from core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/index.html rename to core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/ui/proxy/login.html index 84c136d..a0d2c98 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/static-content/index.html +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/resources/cxdevproxy/ui/proxy/login.html @@ -3,7 +3,7 @@ - CX Dev Proxy - Portal + #{page.title:CX Dev Proxy - Portal}
-

🚀 CX Dev Proxy

+

🚀 #{heading.main:CX Dev Proxy}

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

🢠Employees (Internal Apps)

+

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

-

admin

Shop Admin (Full Access)

+

admin

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

-

cmsmanager

SmartEdit & CMS Access

+

cmsmanager

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

-

productmanager

PIM & Backoffice Access

+

productmanager

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

-

ðŸ›ï¸ Customers (Storefront)

-
-
-

besucher

Max Besucher

+

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

+
+
+

visitor

#{user.anonymous:B2C Visitor}

+
+
+

customer

#{user.customer:B2C Customer}

+
+
+ +

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

+
+
+

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