You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ofbiz.apache.org by lf...@apache.org on 2026/07/08 12:42:45 UTC

(ofbiz-framework) branch trunk updated (93de9a21f5 -> df8fcbc982)

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

Lukas-Finster pushed a change to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


    from 93de9a21f5 Improved: Clarify security assumptions regarding default configuration usage in OFBiz
     new 143c04d5f5 Fix: Guard against NPE in ModelApiReader when service Element is missing in ModelResource (OFBIZ-13443)
     new df8fcbc982 Improved: Added basic JUnit test for relevant rest-api classes and integration test for rest-api services (OFBIZ-13443)

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 framework/rest-api/ofbiz-component.xml             |   2 -
 .../apache/ofbiz/ws/rs/model/ModelApiReader.java   |  24 ++-
 .../apache/ofbiz/ws/rs/test/RestServicesTests.java | 213 +++++++++++++++++++++
 .../ofbiz/ws/rs/core/OFBizApiConfigTest.java       | 107 +++++++++++
 .../ofbiz/ws/rs/core/ResponseStatusTest.java       |  70 +++++++
 .../ws/rs/listener/ApiContextListenerTest.java     |  72 +++++++
 .../ofbiz/ws/rs/model/ModelApiReaderTest.java      | 174 +++++++++++++++++
 .../ws/rs/openapi/OFBizOpenApiReaderTest.java      | 185 ++++++++++++++++++
 .../ws/rs/openapi/OFBizResourceScannerTest.java    |  96 ++++++++++
 .../ws/rs/process/RestRequestHandlerTest.java      | 168 ++++++++++++++++
 .../ofbiz/ws/rs/resources/ApiRootResourceTest.java |  80 ++++++++
 .../ofbiz/ws/rs/resources/OpenApiResourceTest.java |  75 ++++++++
 .../apache/ofbiz/ws/rs/util/RestApiUtilTest.java   | 159 +++++++++++++++
 framework/rest-api/testdef/rest-apiTests.xml       |   3 +
 14 files changed, 1417 insertions(+), 11 deletions(-)
 create mode 100644 framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java
 create mode 100644 framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java


(ofbiz-framework) 01/02: Fix: Guard against NPE in ModelApiReader when service Element is missing in ModelResource (OFBIZ-13443)

Posted by lf...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Lukas-Finster pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit 143c04d5f5f17e2380b0d57e4a90dff984acc05c
Author: Lukas Finster <lu...@ecomify.de>
AuthorDate: Thu Jul 2 13:13:37 2026 +0200

    Fix: Guard against NPE in ModelApiReader when service Element is missing in ModelResource (OFBIZ-13443)
---
 .../apache/ofbiz/ws/rs/model/ModelApiReader.java   | 24 ++++++++++++++--------
 1 file changed, 15 insertions(+), 9 deletions(-)

diff --git a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/model/ModelApiReader.java b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/model/ModelApiReader.java
index af099d3156..e6110c8cf1 100644
--- a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/model/ModelApiReader.java
+++ b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/model/ModelApiReader.java
@@ -25,6 +25,7 @@ import java.io.IOException;
 import javax.xml.parsers.ParserConfigurationException;
 
 import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilValidate;
 import org.apache.ofbiz.base.util.UtilXml;
 import org.w3c.dom.Element;
 import org.xml.sax.SAXException;
@@ -104,15 +105,20 @@ public final class ModelApiReader {
     private static void createOperations(Element resourceEle, ModelResource resource) {
         for (Element operationEle : UtilXml.childElementList(resourceEle, "operation")) {
             Element serviceEle = UtilXml.firstChildElement(operationEle, "service");
-            String serviceName = UtilXml.checkEmpty(serviceEle.getAttribute("name")).intern();
-            ModelOperation op = new ModelOperation()
-                    .path(UtilXml.checkEmpty(operationEle.getAttribute("path")).intern())
-                    .verb(UtilXml.checkEmpty(operationEle.getAttribute("verb")).intern()).service(serviceName)
-                    .produces(UtilXml.checkEmpty(operationEle.getAttribute("produces")).intern())
-                    .consumes(UtilXml.checkEmpty(operationEle.getAttribute("consumes")).intern())
-                    .description(UtilXml.checkEmpty(operationEle.getAttribute("description")).intern())
-                    .auth(Boolean.parseBoolean(UtilXml.checkEmpty(operationEle.getAttribute("auth")).intern()));
-            resource.addOperation(op);
+            if (!UtilValidate.isEmpty(serviceEle)) {
+                String serviceName = UtilXml.checkEmpty(serviceEle.getAttribute("name")).intern();
+                ModelOperation op = new ModelOperation()
+                        .path(UtilXml.checkEmpty(operationEle.getAttribute("path")).intern())
+                        .verb(UtilXml.checkEmpty(operationEle.getAttribute("verb")).intern()).service(serviceName)
+                        .produces(UtilXml.checkEmpty(operationEle.getAttribute("produces")).intern())
+                        .consumes(UtilXml.checkEmpty(operationEle.getAttribute("consumes")).intern())
+                        .description(UtilXml.checkEmpty(operationEle.getAttribute("description")).intern())
+                        .auth(Boolean.parseBoolean(UtilXml.checkEmpty(operationEle.getAttribute("auth")).intern()));
+                resource.addOperation(op);
+            } else {
+                Debug.logWarning("Error during creation of ModelApi, due to missing 'service' Attribute in ApiModelXml for"
+                        + "ModelResource [%s]", MODULE, resource.getName());
+            }
         }
     }
 


(ofbiz-framework) 02/02: Improved: Added basic JUnit test for relevant rest-api classes and integration test for rest-api services (OFBIZ-13443)

Posted by lf...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Lukas-Finster pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit df8fcbc9820e7a4392487fddb8fbc8a14c22e2cc
Author: Lukas Finster <lu...@ecomify.de>
AuthorDate: Thu Jun 18 14:54:06 2026 +0200

    Improved: Added basic JUnit test for relevant rest-api classes and
    integration test for rest-api services (OFBIZ-13443)
---
 framework/rest-api/ofbiz-component.xml             |   2 -
 .../apache/ofbiz/ws/rs/test/RestServicesTests.java | 213 +++++++++++++++++++++
 .../ofbiz/ws/rs/core/OFBizApiConfigTest.java       | 107 +++++++++++
 .../ofbiz/ws/rs/core/ResponseStatusTest.java       |  70 +++++++
 .../ws/rs/listener/ApiContextListenerTest.java     |  72 +++++++
 .../ofbiz/ws/rs/model/ModelApiReaderTest.java      | 174 +++++++++++++++++
 .../ws/rs/openapi/OFBizOpenApiReaderTest.java      | 185 ++++++++++++++++++
 .../ws/rs/openapi/OFBizResourceScannerTest.java    |  96 ++++++++++
 .../ws/rs/process/RestRequestHandlerTest.java      | 168 ++++++++++++++++
 .../ofbiz/ws/rs/resources/ApiRootResourceTest.java |  80 ++++++++
 .../ofbiz/ws/rs/resources/OpenApiResourceTest.java |  75 ++++++++
 .../apache/ofbiz/ws/rs/util/RestApiUtilTest.java   | 159 +++++++++++++++
 framework/rest-api/testdef/rest-apiTests.xml       |   3 +
 13 files changed, 1402 insertions(+), 2 deletions(-)

diff --git a/framework/rest-api/ofbiz-component.xml b/framework/rest-api/ofbiz-component.xml
index ff6785703b..d256e386dd 100644
--- a/framework/rest-api/ofbiz-component.xml
+++ b/framework/rest-api/ofbiz-component.xml
@@ -30,9 +30,7 @@ under the License.
     <!-- service resources: model(s), eca(s) and group definitions -->
     <service-resource type="model" loader="main" location="servicedef/services.xml"/>
 
-    <!--
     <test-suite loader="main" location="testdef/rest-apiTests.xml"/>
-    -->
 
     <!-- web applications; will be mounted when using the embedded container -->
     <webapp name="rest-api"
diff --git a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java
new file mode 100644
index 0000000000..c325fc1434
--- /dev/null
+++ b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/test/RestServicesTests.java
@@ -0,0 +1,213 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+
+package org.apache.ofbiz.ws.rs.test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.service.ServiceUtil;
+import org.apache.ofbiz.service.testtools.OFBizTestCase;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class RestServicesTests extends OFBizTestCase {
+
+    public RestServicesTests(String name) {
+        super(name);
+    }
+
+    /**
+     * Verifies that the {@code generateAuthTokenService} returns a success response
+     * when called with a valid {@code userLogin} in the service context.
+     *
+     * <p>This is the happy-path test — it confirms the service completes without
+     * error and that {@link ServiceUtil#isSuccess(Map)} returns {@code true}.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly
+     */
+    public void testGenerateAuthTokenReturnsSuccess() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+        assertNotNull("admin userLogin must exist in demo data", userLogin);
+
+        Map<String, Object> ctx = UtilMisc.toMap("userLogin", (Object) userLogin);
+        Map<String, Object> result = getDispatcher().runSync("generateAuthTokenService", ctx);
+
+        assertTrue("Service should return success", ServiceUtil.isSuccess(result));
+    }
+
+    /**
+     * Verifies that the {@code access_token} output attribute is present and non-empty
+     * in the service response.
+     *
+     * <p>A null or empty token would indicate that {@code JWTManager.createJwt()}
+     * failed silently or returned an unexpected value.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly
+     */
+    public void testGenerateAuthTokenAccessTokenPresent() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        String accessToken = (String) result.get("access_token");
+        assertNotNull("access_token should not be null", accessToken);
+        assertFalse("access_token should not be empty", accessToken.isEmpty());
+    }
+
+    /**
+     * Verifies that the {@code token_type} output attribute is exactly {@code "Bearer"}.
+     *
+     * <p>REST clients rely on this value to correctly construct the
+     * {@code Authorization: Bearer <token>} header. Any deviation would break
+     * standard OAuth2 / JWT bearer token flows.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly
+     */
+    public void testGenerateAuthTokenTokenTypeIsBearer() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        assertEquals("token_type should be Bearer", "Bearer", result.get("token_type"));
+    }
+
+    /**
+     * Verifies that the {@code expires_in} output attribute is present and represents
+     * a valid positive integer (in seconds).
+     *
+     * <p>The value is read from the {@code security.jwt.token.expireTime} property
+     * in {@code security.properties}. This test guards against a missing property
+     * file entry or a non-numeric value being returned.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly,
+     *                   or if {@code expires_in} cannot be parsed as an integer
+     */
+    public void testGenerateAuthTokenExpiresInIsValid() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        String expiresIn = (String) result.get("expires_in");
+        assertNotNull("expires_in should not be null", expiresIn);
+
+        int expiresCurrent = Integer.parseInt(expiresIn);
+        assertTrue("expires_in should be a positive number", expiresCurrent > 0);
+
+        int expiresTarget = 1800;
+        assertTrue("expires_in should match the configured amount", expiresCurrent == expiresTarget);
+    }
+
+    /**
+     * Verifies that the {@code access_token} is a well-formed JWT by checking
+     * it consists of exactly three Base64-encoded parts separated by dots
+     * ({@code header.payload.signature}).
+     *
+     * <p>This does not validate the cryptographic signature — it confirms that
+     * {@code JWTManager.createJwt()} produced a structurally valid token that
+     * JWT libraries and API clients will be able to parse.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly
+     */
+    public void testGenerateAuthTokenTokenIsValidJwtFormat() throws Exception {
+        GenericValue userLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+
+        Map<String, Object> result = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) userLogin));
+
+        String accessToken = (String) result.get("access_token");
+        String[] parts = accessToken.split("\\.");
+        assertEquals("JWT should have 3 parts (header.payload.signature)", 3, parts.length);
+    }
+
+    /**
+     * Verifies that two different users receive different tokens.
+     *
+     * <p>Since the {@code userLoginId} is embedded in the JWT payload, tokens
+     * generated for different users must not be identical. Identical tokens would
+     * indicate that the {@code userLogin} is not being read from context correctly,
+     * allowing one user to authenticate as another.</p>
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly
+     */
+    public void testGenerateAuthTokenDifferentUsersGetDifferentTokens() throws Exception {
+        GenericValue adminLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+        GenericValue systemLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "system"), false);
+
+        assertNotNull("admin userLogin must exist", adminLogin);
+        assertNotNull("system userLogin must exist", systemLogin);
+
+        Map<String, Object> adminResult = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) adminLogin));
+
+        Map<String, Object> systemResult = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) systemLogin));
+
+        assertFalse(
+                "Different users should receive different tokens",
+                adminResult.get("access_token").equals(systemResult.get("access_token")));
+    }
+
+    /**
+     * Verifies JWT payload hast correct issuer and userLoginId.
+     *
+     * @throws Exception if the service call or entity lookup fails unexpectedly
+     */
+    public void testGenerateAuthTokenIssuerAndUserLoginIdInPayload() throws Exception {
+        GenericValue adminLogin = getDelegator().findOne("UserLogin", UtilMisc.toMap("userLoginId", "admin"), false);
+        Map<String, Object> adminResult = getDispatcher().runSync(
+                "generateAuthTokenService",
+                UtilMisc.toMap("userLogin", (Object) adminLogin));
+        String jwt = (String) adminResult.get("access_token");
+
+        String[] parts = jwt.split("\\.");
+        String payloadSegment = parts[1];
+        byte[] decodedBytes = Base64.getUrlDecoder().decode(padBase64(payloadSegment));
+        String jwtDecoded = new String(decodedBytes, StandardCharsets.UTF_8);
+
+        ObjectMapper mapper = new ObjectMapper();
+        JsonNode claims = mapper.readTree(jwtDecoded);
+
+        String iss = claims.path("iss").asText(null);
+        String userLoginId = claims.path("userLoginId").asText(null);
+
+        assertTrue("Issuer is ApacheOFBiz", iss.equals("ApacheOFBiz"));
+        assertTrue("UserLoginId is admin", userLoginId.equals("admin"));
+    }
+
+    private static String padBase64(String input) {
+        int padding = (4 - input.length() % 4) % 4;
+        StringBuilder sb = new StringBuilder(input);
+        for (int i = 0; i < padding; i++) sb.append('=');
+        return sb.toString();
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java
new file mode 100644
index 0000000000..2e9dd22b26
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/OFBizApiConfigTest.java
@@ -0,0 +1,107 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.lang.reflect.Method;
+
+import org.junit.jupiter.api.Test;
+
+class OFBizApiConfigTest {
+
+    @Test
+    void testBuildCleanPathRemovesDuplicateSlashes() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        "/party/",
+                        "/customers/",
+                        "/{partyId}/"
+                });
+
+        assertEquals("party/customers/{partyId}", path);
+    }
+
+    @Test
+    void testBuildCleanPathIgnoresNullParts() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        null,
+                        "",
+                        " ",
+                        "/party/"
+                });
+
+        assertEquals("party", path);
+    }
+
+    @Test
+    void testBuildCleanPathReturnsEmptyStringWhenNoValidParts() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        "",
+                        null,
+                        "   "
+                });
+
+        assertEquals("", path);
+    }
+
+    @Test
+    void testBuildCleanPathWithSinglePart() throws Exception {
+        OFBizApiConfig config = new OFBizApiConfig();
+
+        Method method = OFBizApiConfig.class.getDeclaredMethod(
+                "buildCleanPath",
+                String[].class);
+        method.setAccessible(true);
+
+        String path = (String) method.invoke(
+                config,
+                (Object) new String[] {
+                        "/services/"
+                });
+
+        assertEquals("services", path);
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java
new file mode 100644
index 0000000000..8cb1837b30
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/core/ResponseStatusTest.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.core;
+
+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.assertSame;
+
+import org.apache.ofbiz.ws.rs.core.ResponseStatus.Custom;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.core.Response.Status.Family;
+
+class ResponseStatusTest {
+
+    @Test
+    void testUnprocessableEntityHasCorrectStatusCode() {
+        assertEquals(422, Custom.UNPROCESSABLE_ENTITY.getStatusCode());
+    }
+
+    @Test
+    void testUnprocessableEntityHasCorrectReasonPhrase() {
+        assertEquals("Unprocessable Entity", Custom.UNPROCESSABLE_ENTITY.getReasonPhrase());
+    }
+
+    @Test
+    void testToStringReturnsReasonPhrase() {
+        assertEquals("Unprocessable Entity", Custom.UNPROCESSABLE_ENTITY.toString());
+    }
+
+    @Test
+    void testFamilyIsClientError() {
+        // 422 falls in the 4xx range
+        assertEquals(Family.CLIENT_ERROR, Custom.UNPROCESSABLE_ENTITY.getFamily());
+    }
+
+    @Test
+    void testFromStatusCodeReturnsMatchingEnum() {
+        Custom result = Custom.fromStatusCode(422);
+        assertNotNull(result);
+        assertSame(Custom.UNPROCESSABLE_ENTITY, result);
+    }
+
+    @Test
+    void testFromStatusCodeReturnsNullForUnknownStatusCode() {
+        assertNull(Custom.fromStatusCode(999));
+    }
+
+    @Test
+    void testFromStatusCodeReturnsNullForStandardStatusCode() {
+        assertNull(Custom.fromStatusCode(404));
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java
new file mode 100644
index 0000000000..a6f301204b
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/listener/ApiContextListenerTest.java
@@ -0,0 +1,72 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.listener;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.DelegatorFactory;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.apache.ofbiz.service.ServiceContainer;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletContextEvent;
+
+class ApiContextListenerTest {
+
+    @Test
+    void testContextInitializedWiresDelegatorAndDispatcherUnderCorrectKeys() {
+        ServletContext servletContext = mock(ServletContext.class);
+        ServletContextEvent event = mock(ServletContextEvent.class);
+        when(event.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getInitParameter("entityDelegatorName")).thenReturn("default");
+        when(servletContext.getInitParameter("localDispatcherName")).thenReturn("api-dispatcher");
+
+        Delegator delegator = mock(Delegator.class);
+        LocalDispatcher dispatcher = mock(LocalDispatcher.class);
+
+        try (MockedStatic<DelegatorFactory> delegatorFactory = mockStatic(DelegatorFactory.class);
+                MockedStatic<ServiceContainer> serviceContainer = mockStatic(ServiceContainer.class)) {
+            delegatorFactory.when(() -> DelegatorFactory.getDelegator("default")).thenReturn(delegator);
+            serviceContainer.when(() -> ServiceContainer.getLocalDispatcher("api-dispatcher", delegator)).thenReturn(dispatcher);
+
+            new ApiContextListener().contextInitialized(event);
+
+            verify(servletContext).setAttribute("delegator", delegator);
+            verify(servletContext).setAttribute("dispatcher", dispatcher);
+        }
+    }
+
+    @Test
+    void testContextDestroyedRemovesDelegatorAndDispatcherAttributes() {
+        ServletContext servletContext = mock(ServletContext.class);
+        ServletContextEvent event = mock(ServletContextEvent.class);
+        when(event.getServletContext()).thenReturn(servletContext);
+
+        new ApiContextListener().contextDestroyed(event);
+
+        verify(servletContext).removeAttribute("delegator");
+        verify(servletContext).removeAttribute("dispatcher");
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java
new file mode 100644
index 0000000000..5cb0f398da
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/model/ModelApiReaderTest.java
@@ -0,0 +1,174 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Integration-style tests for {@link ModelApiReader}.
+ *
+ * <p>These tests deliberately parse real, temporary {@code *.rest.xml} files
+ * rather than mocking {@code UtilXml}. The behavior under test is the
+ * mapping between XML attribute names and {@link ModelApi} /
+ * {@link ModelResource} / {@link ModelOperation} fields, which can only be
+ * verified meaningfully against a real XML document.</p>
+ */
+class ModelApiReaderTest {
+
+    @TempDir
+    private File tempDir;
+
+    private File writeXml(String content) throws IOException {
+        File file = new File(tempDir, "test.rest.xml");
+        Files.writeString(file.toPath(), content);
+        return file;
+    }
+
+    @Test
+    void testGetModelApiParsesTopLevelAttributes() throws IOException {
+        File file = writeXml("""
+                <api displayName="My API" name="myApi" description="desc" path="/api" publish="true"/>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        assertEquals("My API", api.getDisplayName());
+        assertEquals("myApi", api.getName());
+        assertEquals("desc", api.getDescription());
+        assertEquals("/api", api.getPath());
+        assertTrue(api.isPublish());
+    }
+
+    @Test
+    void testGetModelApiMissingPublishAttributeDefaultsToFalse() throws IOException {
+        // No "publish" attribute at all -> UtilXml.checkEmpty(...) yields "",
+        // and Boolean.parseBoolean("") is false. This locks in that implicit
+        // default so a change in checkEmpty/parseBoolean behavior is caught.
+        File file = writeXml("<api name=\"myApi\"/>");
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        assertFalse(api.isPublish());
+    }
+
+    @Test
+    void testGetModelAPiParsesSingleResourceAttributes() throws IOException {
+        File file = writeXml("""
+                <api name="myApi">
+                <resource name="users" description="user resource" displayName="Users"
+                path="/users" publish="true" auth="true"/>
+                </api>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        List<ModelResource> resources = api.getResources();
+        assertEquals(1, resources.size());
+
+        ModelResource users = resources.get(0);
+        assertEquals("users", users.getName());
+        assertEquals("user resource", users.getDescription());
+        assertEquals("Users", users.getDisplayName());
+        assertEquals("/users", users.getPath());
+        assertTrue(users.isPublish());
+        assertTrue(users.isAuth());
+    }
+
+    @Test
+    void testGetModelApiParsesNestedResourcesRecursively() throws IOException {
+        File file = writeXml("""
+                <api name="myApi">
+                <resource name="users" path="/users">
+                <resource name="orders" path="/orders">
+                <resource name="items" path="/items"/>
+                </resource>
+                </resource>
+                </api>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+
+        ModelResource users = api.getResources().get(0);
+        assertEquals("users", users.getName());
+        assertEquals(1, users.getSubResources().size());
+
+        ModelResource orders = users.getSubResources().get(0);
+        assertEquals("orders", orders.getName());
+        assertEquals(1, orders.getSubResources().size());
+
+        ModelResource items = orders.getSubResources().get(0);
+        assertEquals("items", items.getName());
+        assertTrue(items.getSubResources().isEmpty());
+    }
+
+    @Test
+    void testGetModelApiParsesOperationsOnAResource() throws IOException {
+        File file = writeXml("""
+                <api name="myApi">
+                <resource name="orders" path="/orders">
+                <operation path="/{id}" verb="GET" produces="application/json"
+                consumes="application/json" description="Get an order" auth="true">
+                <service name="getOrder"/>
+                </operation>
+                </resource>
+                </api>
+                """);
+
+        ModelApi api = ModelApiReader.getModelApi(file);
+        ModelResource orders = api.getResources().get(0);
+
+        List<ModelOperation> operations = orders.getOperations();
+        assertEquals(1, operations.size());
+
+        ModelOperation getOrder = operations.get(0);
+        assertEquals("/{id}", getOrder.getPath());
+        assertEquals("GET", getOrder.getVerb());
+        assertEquals("application/json", getOrder.getProduces());
+        assertEquals("application/json", getOrder.getConsumes());
+        assertEquals("Get an order", getOrder.getDescription());
+        assertEquals("getOrder", getOrder.getService());
+        assertTrue(getOrder.isAuth());
+    }
+
+    @Test
+    void testGetModelApiThrowsRuntimeExceptionForMalformedXml() throws IOException {
+        File file = writeXml("<api name=\"broken\"><unclosed>");
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> ModelApiReader.getModelApi(file));
+        assertTrue(ex.getMessage().contains("Failed to parse REST API definition"));
+    }
+
+    @Test
+    void getModelApiThrowsForNonExistentFile() {
+        File file = new File(tempDir, "does-not-exist.rest.xml");
+
+        assertThrows(RuntimeException.class, () -> ModelApiReader.getModelApi(file));
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java
new file mode 100644
index 0000000000..c0ff824ac9
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizOpenApiReaderTest.java
@@ -0,0 +1,185 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.openapi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.apache.ofbiz.ws.rs.core.OFBizApiConfig;
+import org.apache.ofbiz.ws.rs.model.ModelApi;
+import org.apache.ofbiz.ws.rs.model.ModelOperation;
+import org.apache.ofbiz.ws.rs.model.ModelResource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import jakarta.servlet.ServletContext;
+
+class OFBizOpenApiReaderTest {
+
+    private OFBizOpenApiReader reader;
+
+    @BeforeEach
+    void setUp() {
+        reader = new OFBizOpenApiReader();
+    }
+
+    @Test
+    void testOpenApiIsGenerated() throws Exception {
+        OpenAPI openAPI = buildOpenApi();
+        assertNotNull(openAPI);
+    }
+
+    @Test
+    void testPathItemExists() throws Exception {
+        OpenAPI openAPI = buildOpenApi();
+        PathItem pathItem = openAPI.getPaths().get("/api/resource/hello");
+        assertNotNull(pathItem);
+    }
+
+    @Test
+    void testGetOperationExists() throws Exception {
+        PathItem pathItem = getPathItem();
+        Operation getOp = pathItem.getGet();
+        assertNotNull(getOp);
+    }
+
+    @Test
+    void testOperationIdMatchesServiceName() throws Exception {
+        Operation getOp = getGetOperation();
+        assertEquals("testService", getOp.getOperationId());
+    }
+
+    @Test
+    void testOperationSummaryMatchesDescription() throws Exception {
+        Operation getOp = getGetOperation();
+        assertEquals("test operation", getOp.getSummary());
+    }
+
+    /**
+     * Verifies that buildNestedUrl correctly joins segments.
+     */
+    @Test
+    void testBuildNestedUrlShouldNormalizeSegments() {
+        List<String> segments = List.of("/api/", "/resource/", "hello");
+
+        String url = OFBizOpenApiReader.buildNestedUrl(segments);
+
+        assertEquals("/api/resource/hello", url);
+    }
+
+    /**
+     * Verifies that empty segments are ignored.
+     */
+    @Test
+    void buildNestedUrlShouldIgnoreEmptySegments() {
+        List<String> segments = new ArrayList<>();
+        segments.add("");
+        segments.add("/");
+        segments.add("api");
+        segments.add(null);
+        segments.add("test");
+
+        String url = OFBizOpenApiReader.buildNestedUrl(segments);
+
+        assertEquals("/api/test", url);
+    }
+
+    /**
+     * Sets up all mocks (dispatcher, servlet context, static access, API model)
+     * and runs reader.read(...), returning the resulting OpenAPI object.
+     */
+    @SuppressWarnings("deprecation")
+    private OpenAPI buildOpenApi() throws Exception {
+        LocalDispatcher dispatcher = mock(LocalDispatcher.class);
+        DispatchContext dctx = mock(DispatchContext.class);
+        when(dispatcher.getDispatchContext()).thenReturn(dctx);
+
+        try (MockedStatic<org.apache.ofbiz.ws.rs.listener.ApiContextListener> ctxMock =
+                        Mockito.mockStatic(org.apache.ofbiz.ws.rs.listener.ApiContextListener.class);
+                MockedStatic<org.apache.ofbiz.webapp.WebAppUtil> webMock =
+                        Mockito.mockStatic(org.apache.ofbiz.webapp.WebAppUtil.class);
+                MockedStatic<OFBizApiConfig> apiMock = Mockito.mockStatic(OFBizApiConfig.class)) {
+
+            ServletContext servletContext = mock(ServletContext.class);
+
+            ctxMock.when(org.apache.ofbiz.ws.rs.listener.ApiContextListener::getApplicationCntx)
+                    .thenReturn(servletContext);
+
+            webMock.when(() -> org.apache.ofbiz.webapp.WebAppUtil.getDispatcher(servletContext))
+                    .thenReturn(dispatcher);
+
+            ModelOperation op = mock(ModelOperation.class);
+            when(op.getPath()).thenReturn("hello");
+            when(op.getVerb()).thenReturn("GET");
+            when(op.getService()).thenReturn("testService");
+            when(op.getDescription()).thenReturn("test operation");
+
+            ModelResource resource = mock(ModelResource.class);
+            when(resource.getPath()).thenReturn("resource");
+            when(resource.getDisplayName()).thenReturn("TestResource");
+            when(resource.getDescription()).thenReturn("desc");
+            when(resource.getOperations()).thenReturn(List.of(op));
+            when(resource.getSubResources()).thenReturn(List.of());
+
+            ModelApi api = mock(ModelApi.class);
+            when(api.isPublish()).thenReturn(true);
+            when(api.getPath()).thenReturn("api");
+            when(api.getResources()).thenReturn(List.of(resource));
+
+            apiMock.when(OFBizApiConfig::getModelApis)
+                    .thenReturn(Map.of("test", api));
+
+            org.apache.ofbiz.service.ModelService service =
+                    mock(org.apache.ofbiz.service.ModelService.class);
+            when(service.getName()).thenReturn("testService");
+            when(service.getInModelParamList()).thenReturn(List.of());
+            when(service.isExport()).thenReturn(true);
+            when(dctx.getModelService("testService")).thenReturn(service);
+
+            return reader.read(Set.of(), Map.of());
+        }
+    }
+
+    private PathItem getPathItem() throws Exception {
+        OpenAPI openAPI = buildOpenApi();
+        PathItem pathItem = openAPI.getPaths().get("/api/resource/hello");
+        assertNotNull(pathItem, "Expected path item for /api/resource/hello");
+        return pathItem;
+    }
+
+    private Operation getGetOperation() throws Exception {
+        Operation getOp = getPathItem().getGet();
+        assertNotNull(getOp, "Expected GET operation to be present");
+        return getOp;
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java
new file mode 100644
index 0000000000..a40580682c
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/openapi/OFBizResourceScannerTest.java
@@ -0,0 +1,96 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.openapi;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class OFBizResourceScannerTest {
+
+    private OFBizResourceScanner scanner;
+
+    @BeforeEach
+    private void setUp() {
+        scanner = new OFBizResourceScanner();
+    }
+
+    /**
+     * Verifies that a {@code null} input is treated as ignored,
+     * consistent with {@code UtilValidate.isEmpty} returning {@code true} for null.
+     */
+    @Test
+    void testIsIgnoredNull() {
+        assertTrue(scanner.isIgnored(null));
+    }
+
+    /**
+     * Verifies that an empty string is treated as ignored.
+     */
+    @Test
+    void testIsIgnoredEmptyString() {
+        assertTrue(scanner.isIgnored(""));
+    }
+
+    /**
+     * Verifies that the exact class name in {@code IGNORED} is ignored.
+     */
+    @Test
+    void testIsIgnoredExactMatch() {
+        assertTrue(scanner.isIgnored("org.apache.ofbiz.ws.rs.resources.OFBizServiceResource"));
+    }
+
+    /**
+     * Verifies that a class name starting with an ignored entry is also ignored.
+     * Since {@code startsWith} is used, any sub-class or nested path under the
+     * ignored prefix is treated as ignored.
+     */
+    @Test
+    void testIsIgnoredPrefixMatch() {
+        assertTrue(scanner.isIgnored("org.apache.ofbiz.ws.rs.resources.OFBizServiceResource$InnerClass"));
+    }
+
+    /**
+     * Verifies that a class name not in {@code IGNORED} and not starting with
+     * any ignored entry is not ignored.
+     */
+    @Test
+    void testIsIgnoredNonMatchingClass() {
+        assertFalse(scanner.isIgnored("org.apache.ofbiz.ws.rs.resources.OFBizOpenApiReader"));
+    }
+
+    /**
+     * Verifies that a partial match that is not a prefix is not ignored.
+     * The ignored entry must match from the start of the string.
+     */
+    @Test
+    void testIsIgnoredPartialNonPrefixMatch() {
+        assertFalse(scanner.isIgnored("com.example.OFBizServiceResource"));
+    }
+
+    /**
+     * Verifies that a completely unrelated class name is not ignored.
+     */
+    @Test
+    void testIsIgnoredUnrelatedClass() {
+        assertFalse(scanner.isIgnored("com.example.SomeOtherResource"));
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java
new file mode 100644
index 0000000000..611d17645d
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/process/RestRequestHandlerTest.java
@@ -0,0 +1,168 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.process;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.glassfish.jersey.server.ContainerRequest;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * Tests for {@link RestRequestHandler#extract(MultivaluedMap)} and
+ * {@link RestRequestHandler#extractRequestBody(ContainerRequestContext)}.
+ *
+ * <p>{@code RestRequestHandler} is abstract and its {@code @Inject} fields
+ * are not relevant to these two methods, so a minimal concrete subclass is
+ * used purely to obtain an instance. {@code extract} and
+ * {@code extractRequestBody} are exercised directly rather than through
+ * {@code apply(...)}.</p>
+ */
+class RestRequestHandlerTest {
+
+    /**
+     * Minimal concrete subclass solely to instantiate the abstract class
+     * under test. {@code execute} is never invoked by the tests below.
+     */
+    private static final class TestHandler extends RestRequestHandler {
+        @Override
+        protected Response execute(ContainerRequestContext data, Map<String, Object> arguments) {
+            return null;
+        }
+    }
+
+    private final TestHandler handler = new TestHandler();
+
+
+    @Test
+    void testExtractSingleValueParameterIsStoredAsPlainString() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.putSingle("testParam", "testValue");
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertEquals("testValue", result.get("testParam"));
+    }
+
+    @Test
+    void testExtractMultiValueParameterIsStoredAsList() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.add("tag", "testValue");
+        params.add("tag", "testValueTwo");
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertEquals(List.of("testValue", "testValueTwo"), result.get("tag"));
+    }
+
+    @Test
+    void testExtractSkipsKeysWithEmptyValueList() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+        params.put("empty", List.of());
+        params.putSingle("present", "value");
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertEquals(1, result.size());
+        assertEquals("value", result.get("present"));
+        assertTrue(!result.containsKey("empty"));
+    }
+
+    @Test
+    void testExtractOfEmptyMapReturnsEmptyMap() {
+        MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
+
+        Map<String, Object> result = handler.extract(params);
+
+        assertTrue(result.isEmpty());
+    }
+
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenNotAContainerRequest() {
+        ContainerRequestContext requestContext = mock(ContainerRequestContext.class);
+
+        Map<String, Object> result = handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+        verifyNoInteractions(requestContext);
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenNoEntity() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(false);
+
+        Map<String, Object> result = handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenContentTypeIsNotJson() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(true);
+        when(requestContext.getMediaType()).thenReturn(MediaType.TEXT_PLAIN_TYPE);
+
+        Map<String, Object> result = handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+        verify(requestContext, org.mockito.Mockito.never()).readEntity(eq(Map.class));
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsEmptyMapWhenJsonEntityIsNull() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(true);
+        when(requestContext.getMediaType()).thenReturn(MediaType.APPLICATION_JSON_TYPE);
+        when(requestContext.readEntity(eq(Map.class))).thenReturn(null);
+
+        Map<String, Object> result = handler.extractRequestBody(requestContext);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    void testExtractRequestBodyReturnsParsedMapForJsonEntity() {
+        ContainerRequest requestContext = mock(ContainerRequest.class);
+        when(requestContext.hasEntity()).thenReturn(true);
+        when(requestContext.getMediaType()).thenReturn(MediaType.APPLICATION_JSON_TYPE);
+        when(requestContext.readEntity(eq(Map.class))).thenReturn(Map.of("id", "123", "active", true));
+
+        Map<String, Object> result = handler.extractRequestBody(requestContext);
+
+        assertEquals("123", result.get("id"));
+        assertEquals(true, result.get("active"));
+        verify(requestContext).bufferEntity();
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java
new file mode 100644
index 0000000000..8bf48f37cd
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/ApiRootResourceTest.java
@@ -0,0 +1,80 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.resources;
+
+import static org.apache.ofbiz.ws.rs.resources.ApiRootResource.joinUri;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class ApiRootResourceTest {
+
+    @ParameterizedTest
+    @CsvSource({
+        "base, path",
+        "base, /path",
+        "base/, path",
+        "base/, /path"
+    })
+    void testJoinUriBaseAndPath(String base, String path) {
+        assertEquals("base/path", joinUri(base, path));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "base, /",
+        "base/, /"
+    })
+    void testJoinUriBase(String base, String path) {
+        assertEquals("base/", joinUri(base, path));
+    }
+
+    @Test
+    void testJoinUriPreservePathParams() {
+        assertEquals("base/path.{myParam}", joinUri("base", "/path.{myParam}"));
+    }
+
+    @Test
+    void testJoinUriOneArgument() {
+        assertEquals("base", joinUri("base"));
+    }
+
+    @Test
+    void testJoinUriEmpty() {
+        assertEquals("", joinUri());
+    }
+
+    // Current known Limitations, change Tests once these change.
+    // Ensures that a change to these is a concious decision
+
+    //No full normalization with double '/' -> //
+    @Test
+    void testJoinUriDoubleSlash() {
+        assertEquals("base//path", joinUri("base//", "path"));
+    }
+
+    // No fault tolerance for null parts
+    @Test
+    void testNullPartThrowsNullPointerException() {
+        assertThrows(NullPointerException.class, () -> joinUri("base", null));
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java
new file mode 100644
index 0000000000..83c4d43ac0
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/resources/OpenApiResourceTest.java
@@ -0,0 +1,75 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.resources;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.ofbiz.ws.rs.openapi.OFBizOpenApiReader;
+import org.apache.ofbiz.ws.rs.openapi.OFBizResourceScanner;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.MockedConstruction;
+import org.mockito.Mockito;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+
+class OpenApiResourceTest {
+
+
+    @SuppressWarnings("try")
+    @ParameterizedTest
+    @CsvSource({
+        "json, application/json",
+        "yaml, application/yaml"
+    })
+    void testGetOpenApi(String type, String expectedType) throws Exception {
+
+        // Mock HTTP request context
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getScheme()).thenReturn("http");
+        when(request.getServerName()).thenReturn("localhost");
+        when(request.getServerPort()).thenReturn(8080);
+        when(request.getContextPath()).thenReturn("/ofbiz");
+
+        OpenApiResource resource = new OpenApiResource();
+
+        // inject request via reflection (since field is @Context)
+        java.lang.reflect.Field f = OpenApiResource.class.getDeclaredField("request");
+        f.setAccessible(true);
+        f.set(resource, request);
+
+        // Mock heavy Swagger builder
+        try (MockedConstruction<org.apache.ofbiz.ws.rs.openapi.OFBizOpenApiReader> readerMock =
+                Mockito.mockConstruction(OFBizOpenApiReader.class, (mock, context) -> { });
+                MockedConstruction<OFBizResourceScanner> scannerMock = Mockito.mockConstruction(OFBizResourceScanner.class)) {
+
+            Response response = resource.getOpenApi(mock(HttpHeaders.class), mock(UriInfo.class), type);
+
+            assertEquals(200, response.getStatus());
+            assertEquals(expectedType, response.getMediaType().toString());
+            assertNotNull(response.getEntity());
+        }
+    }
+}
diff --git a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java
new file mode 100644
index 0000000000..679dc99574
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilTest.java
@@ -0,0 +1,159 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *******************************************************************************/
+package org.apache.ofbiz.ws.rs.util;
+
+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 java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.ofbiz.service.ModelService;
+import org.apache.ofbiz.ws.rs.response.Error;
+import org.apache.ofbiz.ws.rs.response.Success;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+
+public final class RestApiUtilTest {
+
+    private RestApiUtilTest() { }
+
+    @Test
+    void testSuccess() {
+        String message = "Success";
+        String data = "some data";
+
+        Response response = RestApiUtil.success(message, data);
+
+        Success expected = new Success(200, "OK", message, data);
+        Success actual = (Success) response.getEntity();
+
+        assertEquals(expected.getStatusCode(), actual.getStatusCode());
+        assertEquals(expected.getStatusDescription(), actual.getStatusDescription());
+        assertEquals(expected.getSuccessMessage(), actual.getSuccessMessage());
+        assertEquals(expected.getData(), actual.getData());
+    }
+
+    @Test
+    void testError() {
+        String message = "Error";
+        String reason = "reason for error";
+
+        Response response = RestApiUtil.error(400, reason, message);
+
+        Error expected = new Error(400, reason, message);
+        Error actual = (Error) response.getEntity();
+
+        assertEquals(expected.getAdditionalErrors(), actual.getAdditionalErrors());
+        assertEquals(expected.getClass(), actual.getClass());
+        assertEquals(expected.getErrorDesc(), actual.getErrorDesc());
+        assertEquals(expected.getErrorMessage(), actual.getErrorMessage());
+        assertEquals(expected.getStatusCode(), actual.getStatusCode());
+        assertEquals(expected.getStatusDescription(), actual.getStatusDescription());
+        assertEquals(expected.getType(), actual.getType());
+    }
+
+    @Test
+    void testResponseBuilder() {
+        String message = "Error";
+        String reason = "reason for error";
+
+        Response response = RestApiUtil.errorBuilder(400, reason, message).build();
+
+        assertEquals(400, response.getStatus());
+        assertEquals(MediaType.APPLICATION_JSON, response.getMediaType().toString());
+
+        Error expected = (Error) response.getEntity();
+        assertEquals(expected, response.getEntity());
+    }
+
+    @Test
+    void testExtractParams() {
+        MultivaluedMap<String, String> input = new MultivaluedHashMap<>();
+        input.add("name", "John");
+        input.add("role", "admin");
+        input.add("role", "user");
+        input.put("empty", List.of());
+        input.put("null", null);
+
+        Map<String, Object> result = RestApiUtil.extractParams(input);
+
+        // Assert single-value parameter
+        assertEquals("John", result.get("name"));
+        assertTrue(result.get("name") instanceof String);
+
+        // Assert multi-value parameter
+        assertTrue(result.get("role") instanceof List);
+
+        @SuppressWarnings("unchecked")
+        List<String> roles = (List<String>) result.get("role");
+        assertEquals(List.of("admin", "user"), roles);
+
+        // Assert empty parameter is ignored
+        assertFalse(result.containsKey("empty"));
+
+        // Assert null parameter is ignored
+        assertFalse(result.containsKey("null"));
+    }
+
+    @ParameterizedTest
+    @NullSource
+    @ValueSource(strings = { "my/path/without/parameters", ""})
+    void testGetPathParametersReturnEmptyList(String pathInfo) {
+        List<String> pathParameters = RestApiUtil.getPathParameters(pathInfo);
+        assertTrue(pathParameters.isEmpty());
+    }
+
+    @Test
+    void testGetPathParameters() {
+        String pathInfo = "my/path/with/parameters/{parameterOne}/middle/and/end/{parameterTwo}";
+
+        List<String> pathParameters = RestApiUtil.getPathParameters(pathInfo);
+
+        assertTrue(pathParameters.contains("parameterOne"));
+        assertTrue(pathParameters.contains("parameterTwo"));
+    }
+
+    @Test
+    void testBuildErrorFromServiceResult() {
+        Map<String, Object> result = new HashMap<>();
+
+        List<String> errors = new LinkedList<>(List.of("errorOne", "errorTwo", "errorThree"));
+        result.put(ModelService.ERROR_MESSAGE_LIST, errors);
+
+        Response response =
+                RestApiUtil.buildErrorFromServiceResult("TestService", result, Locale.ENGLISH);
+
+        Error error = (Error) response.getEntity();
+
+        assertEquals("errorOne", error.getErrorDesc());
+        assertEquals(List.of("errorTwo", "errorThree"), error.getAdditionalErrors());
+    }
+}
diff --git a/framework/rest-api/testdef/rest-apiTests.xml b/framework/rest-api/testdef/rest-apiTests.xml
index 48e0630b15..3cb93145f5 100644
--- a/framework/rest-api/testdef/rest-apiTests.xml
+++ b/framework/rest-api/testdef/rest-apiTests.xml
@@ -22,5 +22,8 @@ under the License.
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/test-suite.xsd">
 
+    <test-case case-name="rest-api-services-tests">
+        <junit-test-suite class-name="org.apache.ofbiz.ws.rs.test.RestServicesTests"/>
+    </test-case>
 
 </test-suite>
\ No newline at end of file