diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts index fb9201231344..0c21edc81c50 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.ts @@ -57,8 +57,10 @@ export class DotCustomEventHandlerService { this.dotPropertiesService .getKeys([FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]) .subscribe((response) => { - const contentEditorFeatureFlag = - response[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED] === 'true'; + // Accept native boolean true (current backend) or the legacy string 'true' + // (N-1 backend during a rollback window). + const val = response[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]; + const contentEditorFeatureFlag = val === true || val === 'true'; if (!this.handlers) { this.handlers = { diff --git a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java index b7d1fc0d4a45..3181ca7caf01 100644 --- a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java +++ b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java @@ -64,4 +64,11 @@ public interface FeatureFlagName { String FEATURE_FLAG_OPEN_SEARCH_PHASE = "FEATURE_FLAG_OPEN_SEARCH_PHASE"; String FEATURE_FLAG_NEW_BLOCK_EDITOR = "FEATURE_FLAG_NEW_BLOCK_EDITOR"; + + /** + * Enables the new content editor (Edit Content v2). + * Also checked in content-type metadata to opt individual types out. + * Frontend equivalent: {@code FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED}. + */ + String FEATURE_FLAG_CONTENT_EDITOR2_ENABLED = "CONTENT_EDITOR2_ENABLED"; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java index dc8dc61f8c0f..4fe91d4212ac 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java @@ -30,6 +30,7 @@ import com.dotcms.rest.WebResource; import com.dotmarketing.business.Role; import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; import org.glassfish.jersey.server.JSONP; import com.dotcms.rest.ResponseEntityView; import io.swagger.v3.oas.annotations.tags.Tag; @@ -58,14 +59,42 @@ public class ConfigurationResource implements Serializable { private final ConfigurationHelper helper; + private final WebResource webResource; private static final String REPORT_ISSUE_INCLUDE_USER_PII = "REPORT_ISSUE_INCLUDE_USER_PII"; + /** + * Feature flag keys in WHITE_LIST that must be serialised as native JSON booleans. + * All other WHITE_LIST entries (strings, numbers, lists) are left as-is. + * + *

Maintenance rule: every key added here MUST also be present in WHITE_LIST. + * The wire format is the normalised lowercase string {@code "true"} or {@code "false"} — + * frontend callers should compare with {@code === 'true'}. Adding a key here without + * also adding it to WHITE_LIST will silently exclude it from the response. + */ + private static final Set BOOLEAN_FEATURE_FLAGS = ImmutableSet.of( + FeatureFlagName.FEATURE_FLAG_EXPERIMENTS, + FeatureFlagName.DOTFAVORITEPAGE_FEATURE_ENABLE, + FeatureFlagName.FEATURE_FLAG_TEMPLATE_BUILDER_2, + FeatureFlagName.FEATURE_FLAG_SEO_IMPROVEMENTS, + FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS, + FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP, + FeatureFlagName.FEATURE_FLAG_NEW_BINARY_FIELD, + FeatureFlagName.FEATURE_FLAG_ANNOUNCEMENTS, + FeatureFlagName.FEATURE_FLAG_NEW_EDIT_PAGE, + FeatureFlagName.FEATURE_FLAG_UVE_PREVIEW_MODE, + FeatureFlagName.FEATURE_FLAG_UVE_TOGGLE_LOCK, + FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR, + FeatureFlagName.FEATURE_FLAG_PAGE_SCANNER, + FeatureFlagName.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION, + FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR, + FeatureFlagName.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED); + private static final Set WHITE_LIST = ImmutableSet.copyOf( Config.getStringArrayProperty("CONFIGURATION_WHITE_LIST", new String[] {"EMAIL_SYSTEM_ADDRESS", "WYSIWYG_IMAGE_URL_PATTERN", "CHARSET","CONTENT_PALETTE_HIDDEN_CONTENT_TYPES", "DEFAULT_CONTAINER", FeatureFlagName.FEATURE_FLAG_EXPERIMENTS, FeatureFlagName.DOTFAVORITEPAGE_FEATURE_ENABLE, FeatureFlagName.FEATURE_FLAG_TEMPLATE_BUILDER_2, "SHOW_VIDEO_THUMBNAIL", "EXPERIMENTS_MIN_DURATION", "EXPERIMENTS_MAX_DURATION", "EXPERIMENTS_DEFAULT_DURATION", FeatureFlagName.FEATURE_FLAG_SEO_IMPROVEMENTS, - FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS, FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP, "CONTENT_EDITOR2_ENABLED", "CONTENT_EDITOR2_CONTENT_TYPE", + FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS, FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP, FeatureFlagName.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED, "CONTENT_EDITOR2_CONTENT_TYPE", FeatureFlagName.FEATURE_FLAG_NEW_BINARY_FIELD, FeatureFlagName.FEATURE_FLAG_ANNOUNCEMENTS, FeatureFlagName.FEATURE_FLAG_NEW_EDIT_PAGE, FeatureFlagName.FEATURE_FLAG_UVE_PREVIEW_MODE, FeatureFlagName.FEATURE_FLAG_UVE_TOGGLE_LOCK, FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR, FeatureFlagName.FEATURE_FLAG_PAGE_SCANNER, @@ -83,6 +112,15 @@ private boolean isOnBlackList(final String key) { */ public ConfigurationResource() { this.helper = ConfigurationHelper.INSTANCE; + this.webResource = new WebResource(); + } + + /** + * Test constructor — allows injecting a mock {@link WebResource}. + */ + ConfigurationResource(final WebResource webResource) { + this.helper = ConfigurationHelper.INSTANCE; + this.webResource = webResource; } /** @@ -100,10 +138,9 @@ public ConfigurationResource() { @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) public final Response getConfigVariables(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @QueryParam("keys") final String keysQuery) - throws IOException { + @QueryParam("keys") final String keysQuery) { - new WebResource.InitBuilder(request, response) + new WebResource.InitBuilder(webResource) .requiredBackendUser(true) .requestAndResponse(request, response) .rejectWhenNoUser(true) @@ -147,9 +184,45 @@ private Object recoveryFromConfig (final String key) { return Config.getIntProperty(key.replace("number:", StringPool.BLANK), 0); } + if (BOOLEAN_FEATURE_FLAGS.contains(key)) { + return parseBooleanFlag(key); + } + return Config.getStringProperty(key, "NOT_FOUND"); } + /** + * Normalises a feature flag property to the canonical lowercase string {@code "true"} or + * {@code "false"}, preserving the existing string wire format so that consumers built + * against the pre-existing contract continue to work without changes. + * Returns the sentinel {@code "NOT_FOUND"} when the key is not defined anywhere + * (no .properties entry, no DOT_* env override). + * + *

Accepted truthy values (case-insensitive, whitespace-trimmed): {@code "true"}, {@code "1"}. + * Accepted falsy values: {@code "false"}, {@code "0"}, {@code ""}. + * Unrecognised values are logged as WARN and normalise to {@code "false"}. + */ + private static Object parseBooleanFlag(final String key) { + final String rawValue = Config.getStringProperty(key, null); + if (rawValue == null) { + return "NOT_FOUND"; + } + final String normalized = rawValue.trim().toLowerCase(Locale.ROOT); + switch (normalized) { + case "true": + case "1": + return "true"; + case "false": + case "0": + case "": + return "false"; + default: + Logger.warn(ConfigurationResource.class, + () -> "Feature flag '" + key + "' has unrecognized value '" + rawValue + "'; treating as false."); + return "false"; + } + } + /** * Returns the list of system properties that are set through the dotCMS * configuration files. diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/system/ConfigurationResourceTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/system/ConfigurationResourceTest.java new file mode 100644 index 000000000000..a35acb808003 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/system/ConfigurationResourceTest.java @@ -0,0 +1,291 @@ +package com.dotcms.rest.api.v1.system; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.dotcms.featureflag.FeatureFlagName; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotmarketing.util.Config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.util.Map; + +/** + * Unit tests for {@link ConfigurationResource}. + * + *

This class is the single home for all unit-level assertions about + * {@code ConfigurationResource}. Add new test methods here as new behaviour + * is introduced. + * + *

Integration-level tests (requiring a running dotCMS instance) live in + * {@code dotcms-integration/.../ConfigurationResourceTest.java}. + */ +public class ConfigurationResourceTest { + + private static final String FLAG = FeatureFlagName.FEATURE_FLAG_UVE_TOGGLE_LOCK; + private static final String UNDEFINED_FLAG = FeatureFlagName.FEATURE_FLAG_UVE_STYLE_EDITOR; + private static final String NON_FLAG_KEY = "EMAIL_SYSTEM_ADDRESS"; + // A key guaranteed never to appear in WHITE_LIST (fixed UUID-shaped string). + private static final String UNLISTED_KEY = "TEST_KEY_THAT_WILL_NEVER_BE_WHITELISTED_a1b2c3d4"; + + private WebResource webResource; + private ConfigurationResource resource; + private HttpServletRequest request; + private HttpServletResponse response; + private MockedStatic mockedConfig; + + // ── Setup / teardown ────────────────────────────────────────────────────── + + @BeforeEach + void setUp() { + // Open the Config mock BEFORE constructing ConfigurationResource so that + // any static initializer that calls Config (WHITE_LIST, and + // JVMInfoResource.obfuscatePattern via isOnBlackList) receives a safe + // default instead of null. Without this, Pattern.compile(null) inside + // JVMInfoResource throws NullPointerException on first class load. + // + // Default answer: return the caller-supplied default (second argument) + // for any getStringProperty / getStringArrayProperty call that is not + // individually stubbed by a test. + mockedConfig = mockStatic(Config.class); + mockedConfig.when(() -> Config.getStringProperty(anyString(), anyString())) + .thenAnswer(inv -> inv.getArgument(1)); + mockedConfig.when(() -> Config.getStringArrayProperty(anyString(), any(String[].class))) + .thenAnswer(inv -> inv.getArgument(1)); + + webResource = mock(WebResource.class); + resource = new ConfigurationResource(webResource); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + + final InitDataObject initData = mock(InitDataObject.class); + when(webResource.init(any(WebResource.InitBuilder.class))).thenReturn(initData); + } + + @AfterEach + void tearDown() { + mockedConfig.close(); + } + + // ── Truthy variants (AC: "true", "True", "TRUE", " true ", "1" → true) ── + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to lowercase {@code "true"}. + * Expected result: Response contains the normalised string {@code "true"}. + */ + @Test + void getConfigVariables_flagSetToLowercaseTrue_returnsNativeBooleanTrue() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("true"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("true", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to capitalized {@code "True"} — + * the customer-reported reproduction case. + * Expected result: Response contains the normalised string {@code "true"}. + */ + @Test + void getConfigVariables_flagSetToCapitalizedTrue_returnsNativeBooleanTrue() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("True"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("true", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to all-caps {@code "TRUE"}. + * Expected result: Response contains the normalised string {@code "true"}. + */ + @Test + void getConfigVariables_flagSetToUppercaseTrue_returnsNativeBooleanTrue() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("TRUE"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("true", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to {@code " true "} with surrounding whitespace. + * Expected result: Response contains the normalised string {@code "true"} — whitespace is trimmed. + */ + @Test + void getConfigVariables_flagSetToTrueWithWhitespace_returnsNativeBooleanTrue() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn(" true "); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("true", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to the numeric shorthand {@code "1"}. + * Expected result: Response contains the normalised string {@code "true"}. + */ + @Test + void getConfigVariables_flagSetToNumericOne_returnsNativeBooleanTrue() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("1"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("true", entity.get(FLAG)); + } + + // ── Falsy variants (AC: "false", "False", "FALSE", " false ", "0", "" → false) ── + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to lowercase {@code "false"}. + * Expected result: Response contains the normalised string {@code "false"}. + */ + @Test + void getConfigVariables_flagSetToLowercaseFalse_returnsNativeBooleanFalse() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("false"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("false", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to all-caps {@code "FALSE"}. + * Expected result: Response contains the normalised string {@code "false"}. + */ + @Test + void getConfigVariables_flagSetToUppercaseFalse_returnsNativeBooleanFalse() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("FALSE"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("false", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to the numeric shorthand {@code "0"}. + * Expected result: Response contains the normalised string {@code "false"}. + */ + @Test + void getConfigVariables_flagSetToNumericZero_returnsNativeBooleanFalse() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("0"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("false", entity.get(FLAG)); + } + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag is set to an empty string. + * Expected result: Response contains the normalised string {@code "false"}. + */ + @Test + void getConfigVariables_flagSetToEmptyString_returnsNativeBooleanFalse() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn(""); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("false", entity.get(FLAG)); + } + + // ── NOT_FOUND sentinel ──────────────────────────────────────────────────── + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag key is not defined anywhere on the server + * ({@code Config.getStringProperty} returns {@code null}). + * Expected result: Response contains the literal string {@code "NOT_FOUND"}, never a boolean — + * the frontend uses this sentinel to apply its own enabled-by-default opt-out logic. + */ + @Test + void getConfigVariables_flagNotDefined_returnsNotFoundSentinel() { + mockedConfig.when(() -> Config.getStringProperty(UNDEFINED_FLAG, null)).thenReturn(null); + + final Map entity = entityMap( + resource.getConfigVariables(request, response, UNDEFINED_FLAG)); + + assertEquals("NOT_FOUND", entity.get(UNDEFINED_FLAG)); + } + + // ── Unrecognised value ──────────────────────────────────────────────────── + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: A boolean feature flag has an unrecognized value such as {@code "enabled"}. + * Expected result: Response contains the normalised string {@code "false"} — safe default preserved. + */ + @Test + void getConfigVariables_flagSetToUnrecognizedValue_returnsNativeBooleanFalse() { + mockedConfig.when(() -> Config.getStringProperty(FLAG, null)).thenReturn("enabled"); + + final Map entity = entityMap(resource.getConfigVariables(request, response, FLAG)); + + assertEquals("false", entity.get(FLAG)); + } + + // ── Whitelist filtering ─────────────────────────────────────────────────── + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: The caller requests a key that is not on the whitelist. + * Expected result: The key is absent from the response — it is silently excluded. + */ + @Test + void getConfigVariables_keyNotOnWhitelist_isExcludedFromResponse() { + final Map entity = entityMap( + resource.getConfigVariables(request, response, UNLISTED_KEY)); + + assertFalse(entity.containsKey(UNLISTED_KEY)); + } + + // ── Non-boolean whitelisted key ─────────────────────────────────────────── + + /** + * Method to test: {@link ConfigurationResource#getConfigVariables} + * Given scenario: The caller requests a whitelisted key that is not a boolean feature flag + * (e.g. {@code EMAIL_SYSTEM_ADDRESS}). + * Expected result: The raw string value is returned unchanged — boolean coercion is not applied + * to non-flag keys. + */ + @Test + void getConfigVariables_nonFlagWhitelistedKey_returnsRawStringValue() { + final String testAddress = "admin@example.com"; + mockedConfig.when(() -> Config.getStringProperty(NON_FLAG_KEY, "NOT_FOUND")).thenReturn(testAddress); + + final Map entity = entityMap( + resource.getConfigVariables(request, response, NON_FLAG_KEY)); + + assertEquals(testAddress, entity.get(NON_FLAG_KEY)); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static Map entityMap(final Response response) { + return (Map) ((ResponseEntityView) response.getEntity()).getEntity(); + } +}