From a22e3b1930f6f16f505a1f9da7e31f9b66a8e09f Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Fri, 19 Jun 2026 09:49:06 +0200 Subject: [PATCH 1/4] UNOMI-952: Return HTTP 400 for malformed Item JSON in REST requests. Harden ItemDeserializer to reject non-object or incomplete Item payloads with JsonMappingException, and map wrapped JSON deserialization failures to 400 instead of 500. --- .../persistence/spi/ItemDeserializer.java | 36 +++++++- .../persistence/spi/ItemDeserializerTest.java | 90 +++++++++++++++++++ .../exception/RuntimeExceptionMapper.java | 6 +- .../exception/RestExceptionMapperTest.java | 5 +- 4 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java index 32cc13515f..42c9573d27 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.ObjectCodec; import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.unomi.api.CustomItem; @@ -32,7 +34,7 @@ public class ItemDeserializer extends StdDeserializer { private static final long serialVersionUID = -7040054009670771266L; - private Map> classes = new HashMap<>(); + private Map> classes = new HashMap<>(); public ItemDeserializer() { super(Item.class); @@ -49,8 +51,24 @@ public void unregisterMapping(String type) { @Override public Item deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { ObjectCodec codec = jp.getCodec(); - ObjectNode treeNode = codec.readTree(jp); - String type = treeNode.get("itemType").textValue(); + JsonNode jsonNode = codec.readTree(jp); + if (jsonNode == null || jsonNode.isNull()) { + return null; + } + if (!jsonNode.isObject()) { + throw JsonMappingException.from(jp, "Expected a JSON object to deserialize an Item but got " + + describeJsonNode(jsonNode)); + } + ObjectNode treeNode = (ObjectNode) jsonNode; + JsonNode itemTypeNode = treeNode.get("itemType"); + if (itemTypeNode == null || !itemTypeNode.isTextual()) { + throw JsonMappingException.from(jp, "Item JSON object must contain a textual itemType property"); + } + String type = itemTypeNode.textValue(); + JsonNode itemIdNode = treeNode.get("itemId"); + if (itemIdNode == null || !itemIdNode.isTextual()) { + throw JsonMappingException.from(jp, "Item JSON object must contain a textual itemId property"); + } Class objectClass = classes.get(type); if (objectClass == null) { objectClass = CustomItem.class; @@ -58,10 +76,20 @@ public Item deserialize(JsonParser jp, DeserializationContext ctxt) throws IOExc treeNode.remove("itemType"); } Item item = codec.treeToValue(treeNode, objectClass); - item.setItemId(treeNode.get("itemId").asText()); + item.setItemId(itemIdNode.asText()); if (item instanceof CustomItem) { ((CustomItem) item).setCustomItemType(type); } return item; } + + private static String describeJsonNode(JsonNode jsonNode) { + if (jsonNode.isTextual()) { + return "a string"; + } + if (jsonNode.isArray()) { + return "an array"; + } + return jsonNode.getNodeType().name().toLowerCase(); + } } diff --git a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java new file mode 100644 index 0000000000..f48c4df096 --- /dev/null +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java @@ -0,0 +1,90 @@ +/* + * 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.unomi.persistence.spi; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.apache.unomi.api.Item; +import org.apache.unomi.api.Profile; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ItemDeserializerTest { + + private ObjectMapper objectMapper; + + @Before + public void setUp() { + objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Item.class, new ItemDeserializer()); + objectMapper.registerModule(module); + } + + @Test + public void deserialize_validCustomItem() throws Exception { + Item item = objectMapper.readValue("{\"itemType\":\"page\",\"itemId\":\"home\"}", Item.class); + assertNotNull(item); + assertEquals("home", item.getItemId()); + } + + @Test + public void deserialize_nullItem() throws Exception { + assertNull(objectMapper.readValue("null", Item.class)); + } + + @Test(expected = JsonMappingException.class) + public void deserialize_stringValue_throwsJsonMappingException() throws Exception { + objectMapper.readValue("\"not-an-object\"", Item.class); + } + + @Test(expected = JsonMappingException.class) + public void deserialize_emptyObject_throwsJsonMappingException() throws Exception { + objectMapper.readValue("{}", Item.class); + } + + @Test(expected = JsonMappingException.class) + public void deserialize_missingItemId_throwsJsonMappingException() throws Exception { + objectMapper.readValue("{\"itemType\":\"page\"}", Item.class); + } + + @Test(expected = JsonMappingException.class) + public void deserialize_missingItemType_throwsJsonMappingException() throws Exception { + objectMapper.readValue("{\"itemId\":\"home\"}", Item.class); + } + + @Test + public void deserialize_registeredMapping_usesRegisteredClass() throws Exception { + ItemDeserializer deserializer = new ItemDeserializer(); + deserializer.registerMapping(Profile.ITEM_TYPE, Profile.class); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Item.class, deserializer); + ObjectMapper profileMapper = new ObjectMapper(); + profileMapper.registerModule(module); + + Item item = profileMapper.readValue("{\"itemType\":\"profile\",\"itemId\":\"p1\"}", Item.class); + assertNotNull(item); + assertTrue(item instanceof Profile); + assertEquals("p1", item.getItemId()); + } +} diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java index 1b50558a3c..45af06fdc3 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java @@ -39,8 +39,8 @@ public Response toResponse(RuntimeException exception) { ? rootCause.getMessage() : (exception.getMessage() != null ? exception.getMessage() : "")); - // For client errors (like deserialization), log at WARN level. For true server errors, log at ERROR level. - if (isJsonDeserializationError(rootCause)) { + boolean isDeserializationError = isJsonDeserializationError(rootCause); + if (isDeserializationError) { LOGGER.warn( "Bad request on {} - Root cause: {} - {} (Set RuntimeExceptionMapper to debug to get the full stacktrace)", requestContext, rootCauseClassName, rootCauseMessage); @@ -51,6 +51,6 @@ public Response toResponse(RuntimeException exception) { } LOGGER.debug("Full exception details for request: {}", requestContext, exception); - return internalServerErrorResponse(); + return isDeserializationError ? badRequestResponse() : internalServerErrorResponse(); } } diff --git a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java index 8594a89c7d..d187724b7d 100644 --- a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java +++ b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java @@ -48,12 +48,11 @@ void runtimeException_mapsToInternalServerError() { } @Test - void runtimeException_withJsonCause_stillMapsToInternalServerError() { - // RuntimeExceptionMapper only downgrades the log level for JSON causes; the response stays 500. + void runtimeException_withJsonMappingCause_mapsToBadRequest() { RuntimeException exception = new RuntimeException( JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, "cannot deserialize")); Response response = new RuntimeExceptionMapper().toResponse(exception); - assertErrorResponse(response, 500, "internalServerError"); + assertErrorResponse(response, 400, "badRequest"); } @Test From c4d7f27af674aea2bbfb4bdb576572e381082f92 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 20 Jun 2026 07:22:09 +0200 Subject: [PATCH 2/4] UNOMI-952: Harden ItemDeserializer and fix RuntimeExceptionMapper 400 handling. - Use ConcurrentHashMap for thread-safe OSGi registration/deregistration - Override getNullValue() to reject null Item tokens with JsonMappingException - Guard treeToValue() result against null to avoid silent NPE -> 500 - RuntimeExceptionMapper now returns 400 for JsonParseException root causes - Restore WARN/ERROR rationale comment; update RestExceptionMapperTest Javadoc - Expand ItemDeserializerTest: customItemType assertion, array-root rejection, numeric itemType/itemId rejection, unregisterMapping fallback coverage --- .../persistence/spi/ItemDeserializer.java | 15 ++++--- .../persistence/spi/ItemDeserializerTest.java | 41 ++++++++++++++++--- .../exception/RuntimeExceptionMapper.java | 1 + .../exception/RestExceptionMapperTest.java | 13 ++++-- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java index 42c9573d27..62445b776a 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java @@ -28,13 +28,13 @@ import org.apache.unomi.api.Item; import java.io.IOException; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class ItemDeserializer extends StdDeserializer { private static final long serialVersionUID = -7040054009670771266L; - private Map> classes = new HashMap<>(); + private Map> classes = new ConcurrentHashMap<>(); public ItemDeserializer() { super(Item.class); @@ -48,13 +48,15 @@ public void unregisterMapping(String type) { classes.remove(type); } + @Override + public Item getNullValue(DeserializationContext ctxt) throws JsonMappingException { + throw JsonMappingException.from(ctxt, "Item cannot be null"); + } + @Override public Item deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { ObjectCodec codec = jp.getCodec(); JsonNode jsonNode = codec.readTree(jp); - if (jsonNode == null || jsonNode.isNull()) { - return null; - } if (!jsonNode.isObject()) { throw JsonMappingException.from(jp, "Expected a JSON object to deserialize an Item but got " + describeJsonNode(jsonNode)); @@ -76,6 +78,9 @@ public Item deserialize(JsonParser jp, DeserializationContext ctxt) throws IOExc treeNode.remove("itemType"); } Item item = codec.treeToValue(treeNode, objectClass); + if (item == null) { + throw JsonMappingException.from(jp, "Deserializing itemType '" + type + "' produced a null Item"); + } item.setItemId(itemIdNode.asText()); if (item instanceof CustomItem) { ((CustomItem) item).setCustomItemType(type); diff --git a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java index f48c4df096..a9e4870e8c 100644 --- a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; +import org.apache.unomi.api.CustomItem; import org.apache.unomi.api.Item; import org.apache.unomi.api.Profile; import org.junit.Before; @@ -26,7 +27,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class ItemDeserializerTest { @@ -46,11 +46,13 @@ public void deserialize_validCustomItem() throws Exception { Item item = objectMapper.readValue("{\"itemType\":\"page\",\"itemId\":\"home\"}", Item.class); assertNotNull(item); assertEquals("home", item.getItemId()); + assertTrue(item instanceof CustomItem); + assertEquals("page", ((CustomItem) item).getCustomItemType()); } - @Test - public void deserialize_nullItem() throws Exception { - assertNull(objectMapper.readValue("null", Item.class)); + @Test(expected = JsonMappingException.class) + public void deserialize_nullItem_throwsJsonMappingException() throws Exception { + objectMapper.readValue("null", Item.class); } @Test(expected = JsonMappingException.class) @@ -59,8 +61,8 @@ public void deserialize_stringValue_throwsJsonMappingException() throws Exceptio } @Test(expected = JsonMappingException.class) - public void deserialize_emptyObject_throwsJsonMappingException() throws Exception { - objectMapper.readValue("{}", Item.class); + public void deserialize_arrayValue_throwsJsonMappingException() throws Exception { + objectMapper.readValue("[{\"itemType\":\"page\",\"itemId\":\"home\"}]", Item.class); } @Test(expected = JsonMappingException.class) @@ -73,6 +75,16 @@ public void deserialize_missingItemType_throwsJsonMappingException() throws Exce objectMapper.readValue("{\"itemId\":\"home\"}", Item.class); } + @Test(expected = JsonMappingException.class) + public void deserialize_numericItemType_throwsJsonMappingException() throws Exception { + objectMapper.readValue("{\"itemType\":42,\"itemId\":\"home\"}", Item.class); + } + + @Test(expected = JsonMappingException.class) + public void deserialize_numericItemId_throwsJsonMappingException() throws Exception { + objectMapper.readValue("{\"itemType\":\"page\",\"itemId\":99}", Item.class); + } + @Test public void deserialize_registeredMapping_usesRegisteredClass() throws Exception { ItemDeserializer deserializer = new ItemDeserializer(); @@ -87,4 +99,21 @@ public void deserialize_registeredMapping_usesRegisteredClass() throws Exception assertTrue(item instanceof Profile); assertEquals("p1", item.getItemId()); } + + @Test + public void deserialize_unregisterMapping_fallsBackToCustomItem() throws Exception { + ItemDeserializer deserializer = new ItemDeserializer(); + deserializer.registerMapping(Profile.ITEM_TYPE, Profile.class); + deserializer.unregisterMapping(Profile.ITEM_TYPE); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Item.class, deserializer); + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + + Item item = mapper.readValue("{\"itemType\":\"profile\",\"itemId\":\"p1\"}", Item.class); + assertNotNull(item); + assertTrue(item instanceof CustomItem); + assertEquals("profile", ((CustomItem) item).getCustomItemType()); + assertEquals("p1", item.getItemId()); + } } diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java index 45af06fdc3..a87cfbcc82 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java @@ -39,6 +39,7 @@ public Response toResponse(RuntimeException exception) { ? rootCause.getMessage() : (exception.getMessage() != null ? exception.getMessage() : "")); + // Client errors (deserialization) are logged at WARN; genuine server faults at ERROR. boolean isDeserializationError = isJsonDeserializationError(rootCause); if (isDeserializationError) { LOGGER.warn( diff --git a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java index d187724b7d..4ca8ce8854 100644 --- a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java +++ b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java @@ -28,9 +28,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Unit tests for the REST exception mappers (UNOMI-928). These verify the status code and JSON body - * contract directly, without a running container, so the mapper logic is validated deterministically - * regardless of how a given endpoint deserializes its request body. + * Unit tests for the REST exception mappers (UNOMI-928, UNOMI-952). These verify the status code + * and JSON body contract directly, without a running container, so the mapper logic is validated + * deterministically regardless of how a given endpoint deserializes its request body. */ class RestExceptionMapperTest { @@ -55,6 +55,13 @@ void runtimeException_withJsonMappingCause_mapsToBadRequest() { assertErrorResponse(response, 400, "badRequest"); } + @Test + void runtimeException_withJsonParseCause_mapsToBadRequest() { + RuntimeException exception = new RuntimeException(new JsonParseException(null, "malformed")); + Response response = new RuntimeExceptionMapper().toResponse(exception); + assertErrorResponse(response, 400, "badRequest"); + } + @Test void internalServerError_withJsonMappingCause_mapsToBadRequest() { InternalServerErrorException exception = new InternalServerErrorException("wrapped", From c66ef93b4df91c6ce340639cce807bfdfba8b31a Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 20 Jun 2026 09:54:47 +0200 Subject: [PATCH 3/4] UNOMI-952: Polish ItemDeserializer and LogSanitizer after review. - Use textValue() consistently for itemIdNode (matches itemTypeNode pattern) - describeJsonNode fallback now produces "a number/boolean/..." for grammar consistency - Add comment explaining why itemType is stripped only for registered types - Add boolean-root test to cover describeJsonNode fallback branch - Add cycle-guard comment in getRootCause() explaining it is unreachable in practice - Add MAX_MESSAGE_LENGTH cap to LogSanitizer.forLogging() to prevent log amplification --- .../org/apache/unomi/persistence/spi/ItemDeserializer.java | 6 ++++-- .../apache/unomi/persistence/spi/ItemDeserializerTest.java | 5 +++++ .../unomi/rest/exception/AbstractRestExceptionMapper.java | 2 ++ .../java/org/apache/unomi/rest/exception/LogSanitizer.java | 4 ++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java index 62445b776a..4b8265e3ef 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/ItemDeserializer.java @@ -75,13 +75,15 @@ public Item deserialize(JsonParser jp, DeserializationContext ctxt) throws IOExc if (objectClass == null) { objectClass = CustomItem.class; } else { + // Registered Item subclasses don't declare itemType as a Jackson field; remove it + // so treeToValue doesn't fail with an unknown-property error. treeNode.remove("itemType"); } Item item = codec.treeToValue(treeNode, objectClass); if (item == null) { throw JsonMappingException.from(jp, "Deserializing itemType '" + type + "' produced a null Item"); } - item.setItemId(itemIdNode.asText()); + item.setItemId(itemIdNode.textValue()); if (item instanceof CustomItem) { ((CustomItem) item).setCustomItemType(type); } @@ -95,6 +97,6 @@ private static String describeJsonNode(JsonNode jsonNode) { if (jsonNode.isArray()) { return "an array"; } - return jsonNode.getNodeType().name().toLowerCase(); + return "a " + jsonNode.getNodeType().name().toLowerCase(); } } diff --git a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java index a9e4870e8c..50a34a24eb 100644 --- a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/ItemDeserializerTest.java @@ -75,6 +75,11 @@ public void deserialize_missingItemType_throwsJsonMappingException() throws Exce objectMapper.readValue("{\"itemId\":\"home\"}", Item.class); } + @Test(expected = JsonMappingException.class) + public void deserialize_booleanValue_throwsJsonMappingException() throws Exception { + objectMapper.readValue("true", Item.class); + } + @Test(expected = JsonMappingException.class) public void deserialize_numericItemType_throwsJsonMappingException() throws Exception { objectMapper.readValue("{\"itemType\":42,\"itemId\":\"home\"}", Item.class); diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java index e5fe695b7b..5f2f550450 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java @@ -74,6 +74,8 @@ protected Throwable getRootCause(Throwable throwable) { return null; } Throwable current = throwable; + // Standard Java Throwable.initCause() prevents circular cause chains, so the visited-set + // cycle guard below is defensive and not reachable in practice. java.util.Set visited = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); while (current.getCause() != null && current.getCause() != current && visited.add(current)) { current = current.getCause(); diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java index 4b40d75169..e9e2f4f39a 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java @@ -32,6 +32,7 @@ final class LogSanitizer { private static final int MAX_URL_LENGTH = 500; private static final int MAX_QUERY_STRING_LENGTH = 200; + private static final int MAX_MESSAGE_LENGTH = 500; private static final int MAX_CLASS_NAME_LENGTH = 100; private static final int MAX_METHOD_LENGTH = 10; private static final int MAX_QUERY_PARAMS = 10; @@ -52,6 +53,9 @@ static String forLogging(String input) { if (input == null) { return ""; } + if (input.length() > MAX_MESSAGE_LENGTH) { + input = input.substring(0, MAX_MESSAGE_LENGTH) + "...[truncated]"; + } StringBuilder sanitized = new StringBuilder(input.length()); for (int i = 0; i < input.length(); i++) { char c = input.charAt(i); From c2f10a0fa5e2e04f256ad51040da2af3c841376c Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 20 Jun 2026 21:47:26 +0200 Subject: [PATCH 4/4] UNOMI-952: Fix flaky testDedicatedTaskExecutorForSystemTasks The test asserted executionCount==1 after awaiting a latch, but the task runs at a 100ms fixed rate. On a loaded CI runner the second tick could fire between the latch release and the assertEquals, causing a spurious failure. Move preDestroy() before the assertion so the scheduler is fully stopped before we read the counter. --- .../services/impl/scheduler/SchedulerServiceImplTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java index 778c14de86..3a1b04b920 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java @@ -2435,14 +2435,15 @@ public void execute(ScheduledTask task, TaskStatusCallback callback) { assertTrue( firstExecutionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute with dedicated executor"); + + // Stop the scheduler before asserting: the task runs at 100ms fixed-rate, so on a loaded + // CI runner the second tick can fire between the latch release and the assertEquals. + schedulerService.preDestroy(); assertEquals(1, executionCount.get(), "Task should execute once"); // Force refresh to ensure the task is properly saved persistenceService.refreshIndex(ScheduledTask.class); - // Now shut down and restart the scheduler - schedulerService.preDestroy(); - // Create a new scheduler service SchedulerServiceImpl newSchedulerService = (SchedulerServiceImpl) TestHelper.createSchedulerService( "dedicated-executor-scheduler-node",