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 cb6be2b75b Implemented: per-component override of test.api.enabled 
REST test-run API (#1698)
cb6be2b75b is described below

commit cb6be2b75bde55569c45bf50863552a297ca2af3
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sat Aug 22 00:39:32 2026 +0530

    Implemented: per-component override of test.api.enabled REST test-run API 
(#1698)
    
    Implemented: per-component override of test.api.enabled REST test-run API
    
    Explanation
    test.api.enabled (framework/testtools/config/testtools.properties,
    checked in TestRunServices.runTestSuite) is a single global on/off switch 
for the
    REST-triggered test execution API. There was no way to disable just one 
component's
    test-run REST endpoint while leaving the API on for every other component - 
the only
    workarounds were removing the TestRunResource block from a component's 
*.rest.xml (code
    change + redeploy) or revoking the TESTEXEC/TESTEXEC_ADMIN permission (not
    component-specific, affects any component sharing the same permission).
    
    This adds a new property, test.api.enabled.<componentName>, read through
    the same EntityUtilProperties/SystemProperty-backed mechanism the global 
flag
    already uses - so it can be flipped live via a SystemProperty row, without 
a restart, the
    same way the global flag already can. Defaults to enabled ("true") when 
unset, so there is
    no behavior change for any component that doesn't use it. Only consulted 
once the global
    flag is already true, and only gates triggering new runs (runTestSuite) - 
not status
    polling (getTestRunStatus/getScopedTestRunStatus).
    
    Because runScopedTestSuite (used by every component's REST wrapper, e.g.
    plugins/example's runExampleTestSuite) only forces componentName and 
delegates to
    runTestSuite, this single check covers every component's REST endpoint 
automatically, with zero
    changes needed to any wrapper script or *.rest.xml.
    
    No new entity, admin screen, or service - an admin sets/clears the
    override through the existing generic webtools SystemProperty screen (or an 
entity XML data
    import), the same way the global flag is already managed.
    
    Tested manually against a running server: default-enabled behavior with
    no override, disabling one component live via a SystemProperty data import,
    confirming the global flag and every other component stay unaffected, and 
re-enabling live - all
    without a restart.
    
    A companion PR against ofbiz-plugins updates plugins/example's
    runExampleTestSuite service description to mention this gate (no behavior 
change - it already
    inherited the gate automatically).
---
 framework/testtools/config/testtools.properties    |  12 ++
 framework/testtools/servicedef/services.xml        |   4 +-
 .../apache/ofbiz/testtools/TestRunServices.java    |  17 +++
 .../ofbiz/testtools/TestRunServicesTest.java       | 130 +++++++++++++++++++--
 4 files changed, 154 insertions(+), 9 deletions(-)

diff --git a/framework/testtools/config/testtools.properties 
b/framework/testtools/config/testtools.properties
index e2791ba466..e8cb785b70 100644
--- a/framework/testtools/config/testtools.properties
+++ b/framework/testtools/config/testtools.properties
@@ -57,3 +57,15 @@ test.history.days=7
 #    isolated sandbox - see TestRunServices' class javadoc before enabling 
this in any
 #    shared/long-lived environment.
 test.api.enabled=false
+
+# -- Per-component override of test.api.enabled above: 
test.api.enabled.<componentName> (e.g.
+#    test.api.enabled.example) disables - or re-enables - just that 
component's test-run REST
+#    endpoint, without touching any other component's access. Only consulted 
once the global flag
+#    above is already true; defaults to enabled ("true") when unset, so a 
component that never sets
+#    this is unaffected. Like the global flag, read via EntityUtilProperties 
(delegator-aware) - the
+#    normal way to use this is a live SystemProperty row 
(systemResourceId=testtools,
+#    systemPropertyId=test.api.enabled.<componentName>, 
systemPropertyValue=false to disable, true
+#    to re-enable - same true/false convention as this file's own values; 
deleting the row also
+#    re-enables), not a properties-file entry here, though one can be added 
below for a fixed
+#    per-environment default if that's ever needed. Only the literal value 
true enables - any other
+#    value disables.
diff --git a/framework/testtools/servicedef/services.xml 
b/framework/testtools/servicedef/services.xml
index b21535ad7f..626ff5cce4 100644
--- a/framework/testtools/servicedef/services.xml
+++ b/framework/testtools/servicedef/services.xml
@@ -53,7 +53,9 @@ 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. Not
+            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
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 a3f6ffc8af..15ea1334fc 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
@@ -155,6 +155,23 @@ public final class TestRunServices {
             return ServiceUtil.returnError("The test execution API is disabled 
in this environment (test.api.enabled=false)");
         }
 
+        // 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)");
+            }
+        }
+
         // testMethodName reuses the exact same fail-closed validators the 
ofbiz --test method=
         // CLI path already built (TestRunContainer, same package - see its 
javadoc for the full
         // rationale). This first check needs only the two raw strings, not 
any resolved suite, so
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 28aeee062d..dc5ad8a20b 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
@@ -22,14 +22,22 @@ import java.util.Map;
 
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityUtilProperties;
 import org.apache.ofbiz.security.Security;
 import org.apache.ofbiz.service.DispatchContext;
 import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
 
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.when;
 
 class TestRunServicesTest {
@@ -54,10 +62,11 @@ class TestRunServicesTest {
 
     @Test
     void runTestSuiteReturnsErrorWhenApiDisabled() {
-        // No stubbing of testtools.properties overrides: the real classpath 
resource
-        // framework/testtools/config/testtools.properties ships 
test.api.enabled=false (Task 3),
-        // and EntityUtilProperties falls through to it when 
delegator.findOne("SystemProperty", ...)
-        // - unstubbed on this mock - returns null.
+        // Mocks EntityUtilProperties directly (rather than relying on the 
classpath testtools.properties
+        // file's actual value, which may legitimately vary between 
environments) so this test asserts
+        // the exact global-disabled error message, not just a generic error 
that could equally be the
+        // unrelated suite-resolution failure path. Same pattern as
+        // runTestSuiteReturnsErrorWhenComponentApiDisabled below.
         DispatchContext dctx = mock(DispatchContext.class);
         Security security = mock(Security.class);
         Delegator delegator = mock(Delegator.class);
@@ -67,11 +76,18 @@ class TestRunServicesTest {
         when(userLogin.getString("userLoginId")).thenReturn("admin");
         when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
 
-        Map<String, Object> result = TestRunServices.runTestSuite(dctx,
-                Map.of("suiteName", "example-tests", "userLogin", userLogin));
+        try (MockedStatic<EntityUtilProperties> entityUtilProperties =
+                Mockito.mockStatic(EntityUtilProperties.class, 
Mockito.CALLS_REAL_METHODS)) {
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled", 
delegator))
+                    .thenReturn("false");
 
-        assertThat(result.get("responseMessage"), is("error"));
-        assertThat(result.get("runId"), nullValue());
+            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("The test execution API 
is disabled in this environment (test.api.enabled=false)"));
+            assertThat(result.get("runId"), nullValue());
+        }
     }
 
     @Test
@@ -293,4 +309,102 @@ class TestRunServicesTest {
         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
+        // file, the way runTestSuiteReturnsErrorWhenApiDisabled does for the 
global flag) because this
+        // test needs two different property values at once - the global flag 
true, the per-component
+        // override false - which a single checked-in file can't express for 
an arbitrary test-only
+        // component name. CALLS_REAL_METHODS means every other 
EntityUtilProperties call not explicitly
+        // stubbed below still behaves normally.
+        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");
+            entityUtilProperties.when(() -> 
EntityUtilProperties.getPropertyValue("testtools", "test.api.enabled.example", 
delegator))
+                    .thenReturn("false");
+
+            Map<String, Object> result = TestRunServices.runTestSuite(dctx,
+                    Map.of("suiteName", "example-tests", "componentName", 
"example", "userLogin", userLogin));
+
+            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)"));
+            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
+        // 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,
+        // not that a run actually completed). Full enabled-path behavior is 
verified by manual/live
+        // validation (Task 3). Only the absence of the component-disabled 
error is asserted below -
+        // asserting the error response itself would couple this test to the 
unrelated
+        // un-bootstrapped-ComponentConfig failure mode, not the gate this 
test exists to verify.
+        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");
+            // test.api.enabled.example deliberately left unstubbed: 
CALLS_REAL_METHODS falls through to
+            // the real EntityUtilProperties -> mock delegator (findList 
unstubbed, returns null) ->
+            // properties-file fallback, which has no such key either, landing 
on readStringProperty's own
+            // default ("true") - proving the opt-out default, not an explicit 
override.
+
+            Map<String, Object> result = TestRunServices.runTestSuite(dctx,
+                    Map.of("suiteName", "example-tests", "componentName", 
"example", "userLogin", userLogin));
+
+            assertThat((String) result.get("errorMessage"), 
not(containsString("is disabled for component")));
+        }
+    }
+
+    @Test
+    void runTestSuiteSkipsComponentCheckWhenComponentNameIsBlank() {
+        // 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).
+        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");
+
+            TestRunServices.runTestSuite(dctx, Map.of("suiteName", 
"example-tests", "userLogin", userLogin));
+
+            entityUtilProperties.verify(() -> 
EntityUtilProperties.getPropertyValue(eq("testtools"),
+                    startsWith("test.api.enabled."), eq(delegator)), never());
+        }
+    }
 }

Reply via email to