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

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


The following commit(s) were added to refs/heads/trunk by this push:
     new a5cc17f21f Add generic, component-agnostic test-run REST endpoint 
(#1699)
a5cc17f21f is described below

commit a5cc17f21fbd76764c945f0194ea4dbda3055355
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sat Aug 22 17:19:08 2026 +0530

    Add generic, component-agnostic test-run REST endpoint (#1699)
    
    Title: Add generic, component-agnostic test-run REST endpoint
    
    Adds a single REST endpoint, in framework/testtools, that can trigger and 
poll a testdef
    test-suite run for any component:
    
      POST /rest/testtools/testruns/{componentName}
      GET  /rest/testtools/testruns/{runId}
    
    componentName is supplied as a URL path parameter and bound into the 
service context by the
    REST framework's existing path-parameter mechanism - the same mechanism the 
GET operation's
    {runId} already used. The endpoint wires directly to the 
runTestSuite/getTestRunStatus services
    that already existed, unscoped, in 
framework/testtools/servicedef/services.xml.
    
    Why
    
    This replaces a per-component pattern (see the companion ofbiz-plugins pull 
request (apache/ofbiz-plugins#374)) that
    required copy-pasting a groovy wrapper class, two duplicated <service> 
definitions, and a
    component-owned *.rest.xml into every adopting component, purely to 
hard-code that one
    component's name. The generic endpoint needs zero new files for any current 
or future component
    that already has a real test-suite.
    
    TestRunServices.runScopedTestSuite/getScopedTestRunStatus - the helper 
methods that existed only
    to support the old per-component wrapper - are removed, along with their 
tests.
    
    Security fix included
    
    The endpoint's componentName normally comes from the URL path, but REST 
attribute binding merges
    body/path/query/header values onto the same context map, so a caller could 
previously send an
    empty componentName (e.g. an empty query parameter) and bypass the 
per-component
    test.api.enabled.<componentName> toggle entirely, falling back to an 
unscoped sweep across every
    component's tests. runTestSuite now rejects a blank componentName outright, 
restoring the
    fail-closed behavior the old per-component wrapper always had.
    
    Testing
    
    - Unit tests for TestRunServices: 9/9 passing.
    - Manual verification against a running server: triggered several 
components' suites through the
      new endpoint, confirmed PASSED status with the correct componentName 
reported; confirmed a
      deliberately wrong testParams override produces the expected different 
failure, proving the
      override reaches the underlying assertion; confirmed the old 
per-component URL naming pattern
      is not a real route.
    - Full testIntegration run across the whole codebase: 658/658 tests 
passing, zero regressions.
    
    Dependency on the companion pull request
    
    This should merge together with, or after, the companion ofbiz-plugins pull 
request (apache/ofbiz-plugins#374). That PR
    deletes the wrapper scripts that call 
runScopedTestSuite/getScopedTestRunStatus - merging this
    PR first while those scripts are still present would leave them calling 
methods that no longer
    exist.
---
 framework/testtools/api/testruns.rest.xml          |  45 ++++
 framework/testtools/servicedef/services.xml        |  24 +--
 .../apache/ofbiz/testtools/TestRunServices.java    | 127 +++++------
 .../ofbiz/testtools/TestRunServicesTest.java       | 232 +++++----------------
 4 files changed, 152 insertions(+), 276 deletions(-)

diff --git a/framework/testtools/api/testruns.rest.xml 
b/framework/testtools/api/testruns.rest.xml
new file mode 100644
index 0000000000..be2ed9e30b
--- /dev/null
+++ b/framework/testtools/api/testruns.rest.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+
+<api name="TestToolsApi"
+     displayName="Test Tools REST API"
+     path="testtools"
+     description="Generic, component-agnostic REST endpoint to trigger and 
poll testdef test-suite
+         runs for any component, identified by a componentName URL path 
parameter."
+     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+     
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/rest-api.xsd";>
+
+    <resource name="TestRunResource"
+              path="testruns"
+              displayName="Test Run"
+              description="Trigger and poll a testdef test-suite run for any 
component."
+              primaryPermission="TESTEXEC"
+              mainAction="ADMIN">
+
+        <operation verb="post" description="Trigger a test suite run for the 
given component"
+                   path="{componentName}" consumes="application/json">
+            <service name="runTestSuite"/>
+        </operation>
+
+        <operation verb="get" description="Get a test run's status" 
path="{runId}">
+            <service name="getTestRunStatus"/>
+        </operation>
+    </resource>
+</api>
diff --git a/framework/testtools/servicedef/services.xml 
b/framework/testtools/servicedef/services.xml
index 626ff5cce4..3435c2c0cb 100644
--- a/framework/testtools/servicedef/services.xml
+++ b/framework/testtools/servicedef/services.xml
@@ -53,13 +53,14 @@ under the License.
              location="org.apache.ofbiz.testtools.TestRunServices" 
invoke="runTestSuite">
         <description>Kicks off a testdef test-suite run asynchronously via the 
in-JVM Jupiter test
             engine and returns a runId immediately; poll getTestRunStatus for 
progress/results.
-            Gated by the test.api.enabled config flag and the TESTEXEC_ADMIN 
permission. When
-            componentName is set, the per-component 
test.api.enabled.&lt;componentName&gt;
-            override (SystemProperty-overridable, defaults to enabled) is also 
consulted. Not
-            intended for direct *.rest.xml exposure - a component-branded REST 
endpoint must wrap
-            this with a component-scoped service (see 
TestRunServices.runScopedTestSuite and
-            plugins/example's ExampleTestRunServices for the pattern), or the 
endpoint can trigger
-            any component's tests. testMethodName optionally scopes the run to 
one
+            Gated by the test.api.enabled config flag and the TESTEXEC_ADMIN 
permission. componentName
+            is required (rejected if blank) - the per-component 
test.api.enabled.&lt;componentName&gt;
+            override (SystemProperty-overridable, defaults to enabled) is 
always consulted as a result.
+            Exposed directly via the generic, framework-owned 
framework/testtools/api/testruns.rest.xml
+            endpoint (POST /rest/testtools/testruns/{componentName}) - a 
component's own *.rest.xml must
+            never expose this service directly, since that would make an 
intentionally generic,
+            multi-component service masquerade as scoped to just that one 
component's branded URL.
+            testMethodName optionally scopes the run to one
             @Test/@ParameterizedTest method within the class testCaseName 
resolves to - requires
             testCaseName, and only applies when it resolves to a 
jupiter-test-suite.</description>
         <attribute name="componentName" type="String" mode="IN" 
optional="true"/>
@@ -73,11 +74,10 @@ under the License.
     <service name="getTestRunStatus" engine="java" auth="true"
              location="org.apache.ofbiz.testtools.TestRunServices" 
invoke="getTestRunStatus">
         <description>Reads a runTestSuite-triggered run's current status 
(QUEUED/RUNNING/PASSED/
-            FAILED/ERROR) and result summary from the in-memory 
TestRunTracker. Not intended for
-            direct *.rest.xml exposure - a component-branded REST endpoint 
must wrap this with a
-            component-scoped service (see 
TestRunServices.getScopedTestRunStatus and
-            plugins/example's ExampleTestRunServices for the pattern), or the 
endpoint can poll any
-            component's tests.</description>
+            FAILED/ERROR) and result summary from the in-memory TestRunTracker.
+            Exposed directly via the generic, framework-owned 
framework/testtools/api/testruns.rest.xml
+            endpoint (GET /rest/testtools/testruns/{runId}) - same caution as 
runTestSuite above applies to
+            any component-owned *.rest.xml.</description>
         <attribute name="runId" type="String" mode="IN" optional="false"/>
         <attribute name="status" type="String" mode="OUT" optional="true"/>
         <attribute name="componentName" type="String" mode="OUT" 
optional="true"/>
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
index 15ea1334fc..d0d48f6cec 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
@@ -21,7 +21,6 @@ package org.apache.ofbiz.testtools;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -101,19 +100,32 @@ import org.apache.ofbiz.testtools.report.TestRunManifest;
  * independently) is not undone. Anyone deciding whether to enable {@code 
test.api.enabled} (see
  * testtools.properties) should weigh both of the limitations above.
  *
- * <p><b>Do not expose {@link #runTestSuite}/{@link #getTestRunStatus} 
directly in a component's own
- * {@code *.rest.xml}.</b> Both accept/report an arbitrary {@code 
componentName} and so can trigger or
- * poll any component's tests, not just the exposing component's own - a 
component-branded REST
- * endpoint must instead wrap {@link #runScopedTestSuite}/{@link 
#getScopedTestRunStatus} with its own
- * fixed component name, the way {@code plugins/example}'s {@code 
ExampleTestRunServices} does. Writing
- * {@code <service name="runTestSuite"/>} straight into a {@code *.rest.xml} 
reproduces the exact
- * cross-component-reach problem the scoped wrappers exist to close.
+ * <p><b>{@link #runTestSuite}/{@link #getTestRunStatus} are exposed 
directly</b>, via the single,
+ * generic, framework-owned {@code framework/testtools/api/testruns.rest.xml} 
endpoint
+ * ({@code POST /rest/testtools/testruns/{componentName}}, {@code GET 
/rest/testtools/testruns/{runId}}).
+ * {@code componentName} normally comes from that URL's path parameter, but 
REST attribute binding
+ * merges body/path/query/header sources onto the same context map, so a 
caller can still send a
+ * different value (e.g. a same-named query parameter) or an empty one. 
Neither is a vulnerability
+ * this endpoint needs to close: it is deliberately unscoped, so resolving a 
*different, real*
+ * componentName than the URL implies is exactly what it's for, not a bypass 
of anything. An *empty*
+ * componentName is the one case {@link #runTestSuite} does reject outright 
(see the check near the
+ * top of that method) - left unchecked, it would silently degrade into an 
unscoped sweep across every
+ * component's tests, bypassing the per-component {@code 
test.api.enabled.<componentName>} gate below
+ * entirely. <b>Do not</b> write {@code <service name="runTestSuite"/>} (or 
{@code getTestRunStatus})
+ * into a *component's own* {@code *.rest.xml}: doing so would make an 
intentionally generic service
+ * masquerade as scoped to just that component's branded URL, which is exactly 
the confusion this
+ * class's single generic endpoint exists to avoid.
  */
 public final class TestRunServices {
 
     private static final String MODULE = TestRunServices.class.getName();
     private static final String RESOURCE = "testtools";
     private static final String TESTEXEC_PERMISSION = "TESTEXEC_ADMIN";
+    // Client-facing text for both disabled-API rejections below deliberately 
omits the config
+    // property name/value (e.g. "test.api.enabled.example=false") - that 
detail would let a REST
+    // caller enumerate/guess the per-component toggle naming convention. The 
full detail is still
+    // captured server-side via the Debug.logWarning calls at each rejection 
site.
+    private static final String API_DISABLED_MESSAGE = "The test execution API 
is disabled in this environment.";
 
     static final TestRunTracker TRACKER = new TestRunTracker();
     private static final ExecutorService EXECUTOR = 
Executors.newSingleThreadExecutor(runnable -> {
@@ -152,24 +164,38 @@ public final class TestRunServices {
         if (!apiEnabled) {
             Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
                     + " - test.api.enabled is false", MODULE);
-            return ServiceUtil.returnError("The test execution API is disabled 
in this environment (test.api.enabled=false)");
+            return ServiceUtil.returnError(API_DISABLED_MESSAGE);
+        }
+
+        // componentName is required, not merely conventional: the only REST 
route to this service is
+        // the generic framework/testtools/api/testruns.rest.xml endpoint, 
which supplies it as a URL
+        // path parameter - but REST attribute binding still lets a caller 
override that with an empty
+        // value (e.g. a same-named query parameter). Failing closed here, not 
open: skipping this
+        // check would let componentName reach 
ComponentConfig.matchingComponentName as null/blank,
+        // which matches every component - silently turning a request into an 
unscoped sweep across
+        // every component's tests and bypassing the per-component 
test.api.enabled.<componentName>
+        // gate immediately below entirely. See this class's javadoc for the 
broader
+        // caller-suppliable-componentName discussion this guard is part of.
+        if (UtilValidate.isEmpty(componentName)) {
+            Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
+                    + " - componentName is required", MODULE);
+            return ServiceUtil.returnError("runTestSuite requires a 
componentName");
         }
 
         // Per-component override of the global flag above: lets one 
component's REST-triggered test
         // run be disabled (or re-enabled) live via a SystemProperty row, 
without touching every other
         // component's access. Defaults to enabled ("true") when unset, so a 
component that never sets
-        // this behaves exactly as it did before this check existed. Skipped 
when componentName is
-        // blank - an unscoped multi-component suite-name lookup isn't 
attributable to one component's
-        // flag. See 
plugins/supporting-docs/specs/2026-08-21-per-component-test-api-toggle-design.md.
-        if (UtilValidate.isNotEmpty(componentName)) {
-            boolean componentEnabled = "true".equalsIgnoreCase(
-                    readStringProperty(dctx.getDelegator(), 
"test.api.enabled." + componentName, "true"));
-            if (!componentEnabled) {
-                Debug.logWarning("runTestSuite: rejected for user '" + 
userLoginId + "', suite '" + suiteName + "'"
-                        + " - test.api.enabled." + componentName + " is 
false", MODULE);
-                return ServiceUtil.returnError("The test execution API is 
disabled for component '" + componentName
-                        + "' in this environment (test.api.enabled." + 
componentName + "=false)");
-            }
+        // this behaves exactly as it did before this check existed. 
componentName is guaranteed
+        // non-blank by the guard above, so this always runs now - there is no 
longer an unscoped,
+        // no-componentName path through this method to skip it for.
+        // See 
plugins/supporting-docs/specs/2026-08-21-per-component-test-api-toggle-design.md.
+        boolean componentEnabled = "true".equalsIgnoreCase(
+                readStringProperty(dctx.getDelegator(), "test.api.enabled." + 
componentName, "true"));
+        if (!componentEnabled) {
+            Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
+                    + " - test.api.enabled." + componentName + " is false", 
MODULE);
+            return ServiceUtil.returnError("The test execution API is disabled 
for component '" + componentName
+                    + "' in this environment.");
         }
 
         // testMethodName reuses the exact same fail-closed validators the 
ofbiz --test method=
@@ -282,32 +308,6 @@ public final class TestRunServices {
         return result;
     }
 
-    /**
-     * Runs {@link #runTestSuite} with {@code componentName} forced to {@code 
fixedComponentName},
-     * regardless of whatever value (if any) the caller's own context map 
contains - a caller-supplied
-     * componentName is silently overwritten, never honored. Built for 
component-scoped wrapper
-     * services (e.g. plugins/example's runExampleTestSuite): builds a new 
context map rather than
-     * mutating the one it's given, the same defensive-copy discipline 
runTestSuite itself already
-     * applies to testParams - a caller-controlled map must never be assumed 
safe to mutate in place.
-     * @param dctx the dispatch context
-     * @param context the caller's service context - not mutated
-     * @param fixedComponentName the only component this call is allowed to 
resolve suites from
-     * @return the runTestSuite result
-     */
-    public static Map<String, Object> runScopedTestSuite(DispatchContext dctx, 
Map<String, ?> context,
-            String fixedComponentName) {
-        // Fail closed, not open: an empty/null fixedComponentName must never 
reach runTestSuite's
-        // context map. ComponentConfig.matchingComponentName treats a null 
cname as "match every
-        // component" - if this guard were skipped, the entire scoping 
mechanism runScopedTestSuite
-        // exists for would silently degrade to fully unscoped behavior 
instead of erroring out.
-        if (UtilValidate.isEmpty(fixedComponentName)) {
-            return ServiceUtil.returnError("runScopedTestSuite requires a 
fixed componentName");
-        }
-        Map<String, Object> scopedContext = new HashMap<>(context);
-        scopedContext.put("componentName", fixedComponentName);
-        return runTestSuite(dctx, scopedContext);
-    }
-
     /**
      * Runs every ModelTestSuite the wrapper resolved (normally exactly one - 
see
      * JunitSuiteWrapper's suite-name filtering), reporting through a per-run 
SuiteXmlReportWriter
@@ -488,39 +488,6 @@ public final class TestRunServices {
         return result;
     }
 
-    /**
-     * Runs {@link #getTestRunStatus} and, if the run exists, checks its 
recorded componentName
-     * against {@code expectedComponentName} - on a mismatch, returns the 
exact same "No such runId"
-     * error a genuinely unknown runId would produce (never a distinguishable 
"wrong component"
-     * message), so polling can't be used to detect the mere existence of 
another component's runs. A
-     * permission-denied result from the underlying call passes through 
unchanged - the permission
-     * check still runs first, exactly as it does for the unscoped 
getTestRunStatus.
-     * @param dctx the dispatch context
-     * @param context the caller's service context
-     * @param expectedComponentName the only component this call is allowed to 
report on
-     * @return the getTestRunStatus result, or a masked "No such runId" error 
on a component mismatch
-     */
-    public static Map<String, Object> getScopedTestRunStatus(DispatchContext 
dctx, Map<String, ?> context,
-            String expectedComponentName) {
-        // Fail closed, not open: an empty/null expectedComponentName must 
never reach the
-        // equality check below - unlike runScopedTestSuite's 
fixedComponentName (which fails open
-        // by matching every component), a null here would instead throw a raw 
NullPointerException
-        // out of expectedComponentName.equals(...), a different and equally 
unacceptable failure
-        // mode. Guard against both up front so this helper always fails the 
same clean way.
-        if (UtilValidate.isEmpty(expectedComponentName)) {
-            return ServiceUtil.returnError("getScopedTestRunStatus requires an 
expectedComponentName");
-        }
-        Map<String, Object> result = getTestRunStatus(dctx, context);
-        if (ServiceUtil.isError(result)) {
-            return result;
-        }
-        if (!expectedComponentName.equals(result.get("componentName"))) {
-            String runId = (String) context.get("runId");
-            return ServiceUtil.returnError("No such runId: " + runId);
-        }
-        return result;
-    }
-
     private static String readStringProperty(Delegator delegator, String 
propertyName, String defaultValue) {
         try {
             String value = delegator == null
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
index dc5ad8a20b..46eb5b05ba 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
@@ -85,7 +85,7 @@ class TestRunServicesTest {
                     Map.of("suiteName", "example-tests", "userLogin", 
userLogin));
 
             assertThat(result.get("responseMessage"), is("error"));
-            assertThat(result.get("errorMessage"), is("The test execution API 
is disabled in this environment (test.api.enabled=false)"));
+            assertThat(result.get("errorMessage"), is("The test execution API 
is disabled in this environment."));
             assertThat(result.get("runId"), nullValue());
         }
     }
@@ -136,180 +136,6 @@ class TestRunServicesTest {
         assertThat(result.get("componentName"), is("example"));
     }
 
-    @Test
-    void getScopedTestRunStatusReturnsRealDataWhenComponentMatches() {
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-        TestRunServices.TRACKER.register("scoped-run-match", "example-tests", 
"example", "admin", Map.of());
-
-        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
-                Map.of("runId", "scoped-run-match", "userLogin", userLogin), 
"example");
-
-        assertThat(result.get("responseMessage"), is("success"));
-        assertThat(result.get("componentName"), is("example"));
-    }
-
-    @Test
-    void getScopedTestRunStatusMasksAMismatchedComponentAsUnknownRunId() {
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-        TestRunServices.TRACKER.register("scoped-run-mismatch", 
"content-tests", "content", "admin", Map.of());
-
-        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
-                Map.of("runId", "scoped-run-mismatch", "userLogin", 
userLogin), "example");
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("No such runId: 
scoped-run-mismatch"));
-    }
-
-    @Test
-    void getScopedTestRunStatusPassesThroughPermissionDenialUnchanged() {
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(userLogin.getString("userLoginId")).thenReturn("nobody");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
-
-        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
-                Map.of("runId", "any-run", "userLogin", userLogin), "example");
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("You do not have permission 
to view test run status (TESTEXEC_ADMIN)"));
-    }
-
-    @Test
-    void runScopedTestSuitePassesThroughPermissionDenialUnchanged() {
-        // Cannot unit-test the componentName-forcing behavior itself in 
isolation - like
-        // runTestSuite's own suite-resolution path, that needs a real 
bootstrapped ComponentConfig
-        // (see this file's existing tests' pattern, and TestRunServices' own 
"Design note on
-        // testability"). This test only confirms the delegation is wired 
correctly: a permission
-        // denial passes straight through, and passing a deliberately 
mismatched componentName
-        // ("content") in the caller's context doesn't cause a crash before 
the permission check
-        // - proving nothing about whether the override happens, only that the 
wrapper doesn't
-        // reject/mangle the call. The override itself is verified by 
manual/live validation (Task 5).
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        Delegator delegator = mock(Delegator.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(dctx.getDelegator()).thenReturn(delegator);
-        when(userLogin.getString("userLoginId")).thenReturn("nobody");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
-
-        Map<String, Object> result = TestRunServices.runScopedTestSuite(dctx,
-                Map.of("suiteName", "example-tests", "componentName", 
"content", "userLogin", userLogin), "example");
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("runId"), nullValue());
-    }
-
-    @Test
-    void runScopedTestSuiteReturnsErrorForANullFixedComponentName() {
-        // Must fail closed, not open: ComponentConfig.matchingComponentName 
treats a null cname as
-        // "match every component", so skipping this guard would silently turn 
a null
-        // fixedComponentName into fully unscoped behavior instead of an 
error. Uses a permissive
-        // security mock so the guard - not the permission check - is what's 
actually exercised.
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        Delegator delegator = mock(Delegator.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(dctx.getDelegator()).thenReturn(delegator);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-
-        Map<String, Object> result = TestRunServices.runScopedTestSuite(dctx,
-                Map.of("suiteName", "example-tests", "userLogin", userLogin), 
null);
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("runScopedTestSuite requires 
a fixed componentName"));
-        assertThat(result.get("runId"), nullValue());
-    }
-
-    @Test
-    void runScopedTestSuiteReturnsErrorForAnEmptyFixedComponentName() {
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        Delegator delegator = mock(Delegator.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(dctx.getDelegator()).thenReturn(delegator);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-
-        Map<String, Object> result = TestRunServices.runScopedTestSuite(dctx,
-                Map.of("suiteName", "example-tests", "userLogin", userLogin), 
"");
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("runScopedTestSuite requires 
a fixed componentName"));
-        assertThat(result.get("runId"), nullValue());
-    }
-
-    @Test
-    void getScopedTestRunStatusReturnsErrorForANullExpectedComponentName() {
-        // Without this guard, expectedComponentName.equals(...) would throw a 
raw
-        // NullPointerException instead of returning a clean error - a 
different (and equally
-        // unacceptable) failure mode than runScopedTestSuite's fail-open risk.
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-
-        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
-                Map.of("runId", "any-run", "userLogin", userLogin), null);
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("getScopedTestRunStatus 
requires an expectedComponentName"));
-    }
-
-    @Test
-    void getScopedTestRunStatusReturnsErrorForAnEmptyExpectedComponentName() {
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-
-        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
-                Map.of("runId", "any-run", "userLogin", userLogin), "");
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("getScopedTestRunStatus 
requires an expectedComponentName"));
-    }
-
-    @Test
-    void getScopedTestRunStatusMasksANullComponentNameRunAsUnknownRunId() {
-        // Proves the fail-closed guarantee for a run registered with 
componentName=null (e.g. a
-        // hypothetical future unscoped internal registration path): the 
scoped wrapper's equality
-        // check must still deny it, returning the same masked "No such runId" 
response a genuine
-        // mismatch gets - never a crash, never real data.
-        DispatchContext dctx = mock(DispatchContext.class);
-        Security security = mock(Security.class);
-        GenericValue userLogin = mock(GenericValue.class);
-        when(dctx.getSecurity()).thenReturn(security);
-        when(userLogin.getString("userLoginId")).thenReturn("admin");
-        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
-        TestRunServices.TRACKER.register("scoped-run-null-component", 
"content-tests", null, "admin", Map.of());
-
-        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
-                Map.of("runId", "scoped-run-null-component", "userLogin", 
userLogin), "example");
-
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("errorMessage"), is("No such runId: 
scoped-run-null-component"));
-    }
-
     @Test
     void runTestSuiteReturnsErrorWhenComponentApiDisabled() {
         // Mocks EntityUtilProperties directly (rather than relying on the 
classpath testtools.properties
@@ -339,15 +165,14 @@ class TestRunServicesTest {
 
             assertThat(result.get("responseMessage"), is("error"));
             assertThat(result.get("errorMessage"), is("The test execution API 
is disabled for component 'example' "
-                    + "in this environment (test.api.enabled.example=false)"));
+                    + "in this environment."));
             assertThat(result.get("runId"), nullValue());
         }
     }
 
     @Test
     void runTestSuiteDoesNotRejectWhenComponentApiIsNotDisabled() {
-        // Cannot verify a full successful run here - like 
runScopedTestSuite's componentName-forcing
-        // behavior (see 
runScopedTestSuitePassesThroughPermissionDenialUnchanged above), resolving a
+        // Cannot verify a full successful run here - resolving a
         // real suite needs a bootstrapped ComponentConfig this test module 
doesn't have, so
         // JunitSuiteWrapper's constructor still throws and this call still 
ends in error - just not
         // the new component-disabled error this test exists to rule out 
(proving the gate was passed,
@@ -381,12 +206,19 @@ class TestRunServicesTest {
     }
 
     @Test
-    void runTestSuiteSkipsComponentCheckWhenComponentNameIsBlank() {
+    void runTestSuiteRejectsBlankComponentName() {
         // No componentName in context at all - mirrors 
runTestSuiteReturnsErrorWhenApiDisabled's own
-        // context map. The per-component override must never be consulted for 
an unscoped call; this
-        // proves EntityUtilProperties.getPropertyValue is never invoked with 
a "test.api.enabled."-
-        // prefixed property name (the global "test.api.enabled" key itself 
does not match that prefix,
-        // since it has no trailing dot).
+        // context map. Fail closed, not open: this is the only REST route to 
runTestSuite (the generic
+        // framework/testtools/api/testruns.rest.xml endpoint), and REST 
attribute binding merges
+        // body/path/query/header sources onto the same context map, so a 
caller can still send an
+        // empty componentName (e.g. an empty query parameter) despite the 
URL's path parameter
+        // normally supplying a real one. Without this guard, componentName 
would reach
+        // ComponentConfig.matchingComponentName as null/blank - which matches 
every component -
+        // silently turning the request into an unscoped sweep across every 
component's tests. Also
+        // proves the per-component override is never even consulted for a 
rejected call: verifies
+        // EntityUtilProperties.getPropertyValue is never invoked with a 
"test.api.enabled."-prefixed
+        // property name (the global "test.api.enabled" key itself does not 
match that prefix, since
+        // it has no trailing dot).
         DispatchContext dctx = mock(DispatchContext.class);
         Security security = mock(Security.class);
         Delegator delegator = mock(Delegator.class);
@@ -401,10 +233,42 @@ class TestRunServicesTest {
             entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
                     .thenReturn("true");
 
-            TestRunServices.runTestSuite(dctx, Map.of("suiteName", 
"example-tests", "userLogin", userLogin));
+            Map<String, Object> result = TestRunServices.runTestSuite(dctx,
+                    Map.of("suiteName", "example-tests", "userLogin", 
userLogin));
 
+            assertThat(result.get("responseMessage"), is("error"));
+            assertThat(result.get("errorMessage"), is("runTestSuite requires a 
componentName"));
+            assertThat(result.get("runId"), nullValue());
             entityUtilProperties.verify(() -> 
EntityUtilProperties.getPropertyValue(eq("testtools"),
                     startsWith("test.api.enabled."), eq(delegator)), never());
         }
     }
+
+    @Test
+    void runTestSuiteRejectsAnEmptyStringComponentName() {
+        // Distinct from the null/absent case above: an explicitly empty 
string (e.g. what a REST
+        // caller sending "?componentName=" produces) must be rejected the 
same way, not treated as
+        // "present" merely because the key exists in the context map.
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        try (MockedStatic<EntityUtilProperties> entityUtilProperties =
+                Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("true");
+
+            Map<String, Object> result = TestRunServices.runTestSuite(dctx,
+                    Map.of("suiteName", "example-tests", "componentName", "", 
"userLogin", userLogin));
+
+            assertThat(result.get("responseMessage"), is("error"));
+            assertThat(result.get("errorMessage"), is("runTestSuite requires a 
componentName"));
+            assertThat(result.get("runId"), nullValue());
+        }
+    }
 }

Reply via email to