This is an automated email from the ASF dual-hosted git repository. sergehuber pushed a commit to branch UNOMI-928-rest-error-handling in repository https://gitbox.apache.org/repos/asf/unomi.git
commit b676a10f278e73d8c587026268c04a368e4b94cf Author: Serge Huber <[email protected]> AuthorDate: Wed Jun 10 20:05:22 2026 +0200 UNOMI-928: Improve REST API error handling with dedicated exception mappers Add JsonMappingExceptionMapper and InternalServerErrorExceptionMapper so Jackson deserialization failures return 400 badRequest instead of 500. Enrich RuntimeExceptionMapper with sanitized request-context logging. Extract shared logic into AbstractRestExceptionMapper and LogSanitizer. Add unit tests. Backported from unomi-3-dev. --- rest/pom.xml | 7 + .../exception/AbstractRestExceptionMapper.java | 155 +++++++++++++++++++++ .../InternalServerErrorExceptionMapper.java | 81 +++++++++++ ...Mapper.java => JsonMappingExceptionMapper.java} | 38 ++--- .../apache/unomi/rest/exception/LogSanitizer.java | 148 ++++++++++++++++++++ .../rest/exception/RuntimeExceptionMapper.java | 39 +++--- .../unomi/rest/exception/LogSanitizerTest.java | 84 +++++++++++ .../rest/exception/RestExceptionMapperTest.java | 89 ++++++++++++ 8 files changed, 607 insertions(+), 34 deletions(-) diff --git a/rest/pom.xml b/rest/pom.xml index 315b9bbac..2584c8421 100644 --- a/rest/pom.xml +++ b/rest/pom.xml @@ -187,6 +187,13 @@ <artifactId>slf4j-api</artifactId> <scope>provided</scope> </dependency> + + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <version>${junit-jupiter.version}</version> + <scope>test</scope> + </dependency> </dependencies> <build> 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 new file mode 100644 index 000000000..7492529b9 --- /dev/null +++ b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java @@ -0,0 +1,155 @@ +/* + * 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.rest.exception; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import org.apache.cxf.jaxrs.utils.JAXRSUtils; +import org.apache.cxf.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.util.HashMap; +import java.util.Map; + +/** + * Base class for the REST {@code ExceptionMapper}s, factoring out the behaviour they share: + * building a sanitized request context for logging, walking to a root cause, detecting JSON + * deserialization failures, and producing the standard JSON error responses. + */ +public abstract class AbstractRestExceptionMapper { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractRestExceptionMapper.class.getName()); + + private static final String ERROR_MESSAGE_KEY = "errorMessage"; + + /** + * @return a {@code 400 Bad Request} JSON response: {@code {"errorMessage":"badRequest"}} + */ + protected Response badRequestResponse() { + return jsonErrorResponse(Response.Status.BAD_REQUEST, "badRequest"); + } + + /** + * @return a {@code 500 Internal Server Error} JSON response: {@code {"errorMessage":"internalServerError"}} + */ + protected Response internalServerErrorResponse() { + return jsonErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "internalServerError"); + } + + private Response jsonErrorResponse(Response.Status status, String errorMessage) { + Map<String, Object> body = new HashMap<>(); + body.put(ERROR_MESSAGE_KEY, errorMessage); + return Response.status(status).header("Content-Type", MediaType.APPLICATION_JSON).entity(body).build(); + } + + /** + * @return {@code true} when the given root cause is a Jackson deserialization failure, i.e. a + * client error (malformed/mistyped request body) rather than a genuine server fault. + */ + protected boolean isJsonDeserializationError(Throwable rootCause) { + return rootCause instanceof JsonMappingException || rootCause instanceof JsonParseException; + } + + protected Throwable getRootCause(Throwable throwable) { + if (throwable == null) { + return null; + } + Throwable cause = throwable.getCause(); + if (cause == null || cause == throwable) { + return throwable; + } + return getRootCause(cause); + } + + /** + * @return the throwable's message, or its simple class name when no message is available. + */ + protected String messageOrType(Throwable throwable) { + if (throwable == null) { + return ""; + } + String message = throwable.getMessage(); + return (message != null && !message.isEmpty()) ? message : throwable.getClass().getSimpleName(); + } + + /** + * Builds a sanitized "METHOD /path?query" description of the current request for logging. + * Never throws: returns a placeholder when the request context cannot be resolved. + */ + protected String buildRequestContext() { + StringBuilder context = new StringBuilder(); + try { + Message message = JAXRSUtils.getCurrentMessage(); + if (message == null) { + return "REQUEST CONTEXT UNAVAILABLE"; + } + HttpServletRequest request = (HttpServletRequest) message.get("HTTP.REQUEST"); + if (request != null) { + appendFromServletRequest(context, request); + } else { + appendFromCxfMessage(context, message); + } + } catch (Exception e) { + LOGGER.debug("Error building request context", e); + return "REQUEST CONTEXT UNAVAILABLE"; + } + return context.toString(); + } + + private void appendFromServletRequest(StringBuilder context, HttpServletRequest request) { + context.append(LogSanitizer.httpMethod(request.getMethod())) + .append(" ") + .append(LogSanitizer.url(request.getRequestURI())); + String queryString = request.getQueryString(); + if (queryString != null && !queryString.isEmpty()) { + context.append("?").append(LogSanitizer.queryString(queryString)); + } + } + + private void appendFromCxfMessage(StringBuilder context, Message message) { + String httpMethod = (String) message.get(Message.HTTP_REQUEST_METHOD); + String basePath = (String) message.get(Message.BASE_PATH); + String pathInfo = (String) message.get(Message.PATH_INFO); + String requestURI = (String) message.get(Message.REQUEST_URI); + + if (requestURI != null) { + context.append(sanitizedMethodOrUnknown(httpMethod)).append(" ").append(LogSanitizer.url(requestURI)); + } else if (basePath != null || pathInfo != null) { + String path = (basePath != null ? basePath : "") + (pathInfo != null ? pathInfo : ""); + context.append(sanitizedMethodOrUnknown(httpMethod)).append(" ").append(LogSanitizer.url(path)); + } else { + UriInfo uriInfo = message.get(UriInfo.class); + if (uriInfo != null) { + context.append("HTTP ").append(LogSanitizer.url(uriInfo.getPath())); + if (uriInfo.getQueryParameters() != null && !uriInfo.getQueryParameters().isEmpty()) { + context.append("?").append(LogSanitizer.queryParameters(uriInfo.getQueryParameters())); + } + } else { + context.append("UNKNOWN REQUEST"); + } + } + } + + private String sanitizedMethodOrUnknown(String httpMethod) { + return httpMethod != null ? LogSanitizer.httpMethod(httpMethod) : "UNKNOWN"; + } +} diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/InternalServerErrorExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/InternalServerErrorExceptionMapper.java new file mode 100644 index 000000000..5d2f3eac5 --- /dev/null +++ b/rest/src/main/java/org/apache/unomi/rest/exception/InternalServerErrorExceptionMapper.java @@ -0,0 +1,81 @@ +/* + * 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.rest.exception; + +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.InternalServerErrorException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +/** + * Maps {@link InternalServerErrorException}. When the underlying cause is a Jackson deserialization + * failure (a wrapped client error) the response is downgraded to a {@code 400 Bad Request}; + * otherwise it remains a {@code 500 Internal Server Error} with detailed, sanitized logging. + */ +@Provider +@Component(service = ExceptionMapper.class) +public class InternalServerErrorExceptionMapper extends AbstractRestExceptionMapper + implements ExceptionMapper<InternalServerErrorException> { + + private static final Logger LOGGER = LoggerFactory.getLogger(InternalServerErrorExceptionMapper.class.getName()); + + @Override + public Response toResponse(InternalServerErrorException exception) { + String requestContext = buildRequestContext(); + Throwable rootCause = getRootCause(exception); + + // A wrapped JSON deserialization failure is really a client error -> 400 Bad Request. + if (isJsonDeserializationError(rootCause)) { + String errorMessage = LogSanitizer.forLogging(messageOrType(rootCause)); + LOGGER.warn("Bad request on {} - JSON deserialization error: {} (Set InternalServerErrorExceptionMapper to debug to get the full stacktrace)", + requestContext, errorMessage); + LOGGER.debug("Full JSON mapping exception details for request: {}", requestContext, exception); + return badRequestResponse(); + } + + // Genuine server error -> 500 with detailed context. + LOGGER.error("{} (Set InternalServerErrorExceptionMapper to debug to get the full stacktrace)", + buildServerErrorDetails(requestContext, exception, rootCause), exception); + LOGGER.debug("Full exception details for request: {}", requestContext, exception); + + return internalServerErrorResponse(); + } + + private String buildServerErrorDetails(String requestContext, InternalServerErrorException exception, Throwable rootCause) { + StringBuilder errorDetails = new StringBuilder(); + errorDetails.append("Request failed: ").append(requestContext); + + if (rootCause != null && rootCause != exception) { + errorDetails.append(" - Root cause: ").append(LogSanitizer.className(rootCause.getClass().getSimpleName())); + String rootCauseMessage = rootCause.getMessage(); + if (rootCauseMessage != null && !rootCauseMessage.isEmpty()) { + errorDetails.append(" (").append(LogSanitizer.forLogging(rootCauseMessage)).append(")"); + } + } + + String exceptionMessage = exception.getMessage(); + if (exceptionMessage != null && !exceptionMessage.isEmpty() + && (rootCause == null || !exceptionMessage.equals(rootCause.getMessage()))) { + errorDetails.append(" - Error: ").append(LogSanitizer.forLogging(exceptionMessage)); + } + return errorDetails.toString(); + } +} diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/JsonMappingExceptionMapper.java similarity index 50% copy from rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java copy to rest/src/main/java/org/apache/unomi/rest/exception/JsonMappingExceptionMapper.java index c505d57d4..198fd47d7 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/JsonMappingExceptionMapper.java @@ -16,34 +16,36 @@ */ package org.apache.unomi.rest.exception; -import org.apache.commons.lang3.ArrayUtils; +import com.fasterxml.jackson.databind.JsonMappingException; import org.osgi.service.component.annotations.Component; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; -import java.util.HashMap; - +/** + * Maps Jackson {@link JsonMappingException} (raised when a syntactically valid JSON body cannot be + * deserialized into the target type) to a {@code 400 Bad Request}, so a client mistake is not + * reported as a server error. + */ @Provider -@Component(service=ExceptionMapper.class) -public class RuntimeExceptionMapper implements ExceptionMapper<RuntimeException> { - private static final Logger LOGGER = LoggerFactory.getLogger(RuntimeExceptionMapper.class.getName()); +@Component(service = ExceptionMapper.class) +public class JsonMappingExceptionMapper extends AbstractRestExceptionMapper implements ExceptionMapper<JsonMappingException> { + + private static final Logger LOGGER = LoggerFactory.getLogger(JsonMappingExceptionMapper.class.getName()); @Override - public Response toResponse(RuntimeException exception) { - HashMap<String, Object> body = new HashMap<>(); - body.put("errorMessage", "internalServerError"); - LOGGER.error( - "Internal server error {}: {} in {} (Set RuntimeExceptionMapper in debug to get the full stacktrace)", - exception.getMessage(), - exception, - ArrayUtils.isEmpty(exception.getStackTrace()) ? "Stack not available" : exception.getStackTrace()[0] - ); - LOGGER.debug("{}", exception.getMessage(), exception); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).header("Content-Type", MediaType.APPLICATION_JSON).entity(body).build(); + public Response toResponse(JsonMappingException exception) { + String requestContext = buildRequestContext(); + String errorMessage = LogSanitizer.forLogging(messageOrType(exception)); + + // Client error: log at WARN level, full stack trace only at debug level. + LOGGER.warn("Bad request on {} - JSON deserialization error: {} (Set JsonMappingExceptionMapper to debug to get the full stacktrace)", + requestContext, errorMessage); + LOGGER.debug("Full JSON mapping exception details for request: {}", requestContext, exception); + + return badRequestResponse(); } } 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 new file mode 100644 index 000000000..1a67e3166 --- /dev/null +++ b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java @@ -0,0 +1,148 @@ +/* + * 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.rest.exception; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Utility for sanitizing untrusted, request-derived values before they are written to logs. + * <p> + * Centralizes the defenses against log injection (control characters, log-format markers) and + * unbounded log growth (length limits) that are shared by the REST exception mappers. + */ +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_CLASS_NAME_LENGTH = 100; + private static final int MAX_METHOD_LENGTH = 10; + private static final int MAX_QUERY_PARAMS = 10; + private static final int MAX_QUERY_PARAM_VALUE_LENGTH = 50; + + private static final Set<String> VALID_HTTP_METHODS = new HashSet<>(Arrays.asList( + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT")); + + private LogSanitizer() { + } + + /** + * Replaces every character that is not printable ASCII (or is a log-format marker such as + * {@code \ { } % $}) with an underscore. This removes newlines, tabs and other control + * characters that could be used for log injection. + */ + static String forLogging(String input) { + if (input == null) { + return ""; + } + StringBuilder sanitized = new StringBuilder(input.length()); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' && c != '%' && c != '$') { + sanitized.append(c); + } else { + sanitized.append('_'); + } + } + return sanitized.toString(); + } + + static String url(String url) { + if (url == null) { + return "null"; + } + if (url.length() > MAX_URL_LENGTH) { + url = url.substring(0, MAX_URL_LENGTH) + "...[truncated]"; + } + return forLogging(url); + } + + static String queryString(String queryString) { + if (queryString == null) { + return ""; + } + if (queryString.length() > MAX_QUERY_STRING_LENGTH) { + queryString = queryString.substring(0, MAX_QUERY_STRING_LENGTH) + "...[truncated]"; + } + return forLogging(queryString); + } + + static String httpMethod(String method) { + if (method == null || method.isEmpty()) { + return "UNKNOWN"; + } + String sanitized = forLogging(method.toUpperCase()); + if (VALID_HTTP_METHODS.contains(sanitized)) { + return sanitized; + } + if (sanitized.length() > MAX_METHOD_LENGTH) { + return sanitized.substring(0, MAX_METHOD_LENGTH) + "..."; + } + return sanitized; + } + + static String className(String className) { + if (className == null || className.isEmpty()) { + return "Unknown"; + } + StringBuilder sanitized = new StringBuilder(className.length()); + for (int i = 0; i < className.length(); i++) { + char c = className.charAt(i); + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '$' || c == '_' || c == '.') { + sanitized.append(c); + } else { + sanitized.append('_'); + } + } + String result = sanitized.toString(); + if (result.length() > MAX_CLASS_NAME_LENGTH) { + return result.substring(0, MAX_CLASS_NAME_LENGTH) + "..."; + } + return result; + } + + static String queryParameters(Map<String, List<String>> queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + int paramCount = 0; + for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) { + if (paramCount > 0) { + sb.append("&"); + } + if (paramCount >= MAX_QUERY_PARAMS) { + sb.append("...[more params]"); + break; + } + sb.append(url(entry.getKey())).append("="); + if (entry.getValue() != null && !entry.getValue().isEmpty()) { + String value = url(entry.getValue().get(0)); + if (value.length() > MAX_QUERY_PARAM_VALUE_LENGTH) { + value = value.substring(0, MAX_QUERY_PARAM_VALUE_LENGTH) + "..."; + } + sb.append(value); + } + paramCount++; + } + return sb.toString(); + } +} 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 c505d57d4..7c95718c8 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 @@ -16,34 +16,41 @@ */ package org.apache.unomi.rest.exception; -import org.apache.commons.lang3.ArrayUtils; import org.osgi.service.component.annotations.Component; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; -import java.util.HashMap; - @Provider -@Component(service=ExceptionMapper.class) -public class RuntimeExceptionMapper implements ExceptionMapper<RuntimeException> { +@Component(service = ExceptionMapper.class) +public class RuntimeExceptionMapper extends AbstractRestExceptionMapper implements ExceptionMapper<RuntimeException> { + private static final Logger LOGGER = LoggerFactory.getLogger(RuntimeExceptionMapper.class.getName()); @Override public Response toResponse(RuntimeException exception) { - HashMap<String, Object> body = new HashMap<>(); - body.put("errorMessage", "internalServerError"); - LOGGER.error( - "Internal server error {}: {} in {} (Set RuntimeExceptionMapper in debug to get the full stacktrace)", - exception.getMessage(), - exception, - ArrayUtils.isEmpty(exception.getStackTrace()) ? "Stack not available" : exception.getStackTrace()[0] - ); - LOGGER.debug("{}", exception.getMessage(), exception); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).header("Content-Type", MediaType.APPLICATION_JSON).entity(body).build(); + String requestContext = buildRequestContext(); + Throwable rootCause = getRootCause(exception); + String rootCauseClassName = LogSanitizer.className(rootCause != null ? rootCause.getClass().getSimpleName() : "Unknown"); + String rootCauseMessage = LogSanitizer.forLogging(rootCause != null && rootCause.getMessage() != null + ? 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)) { + LOGGER.warn( + "Bad request on {} - Root cause: {} - {} (Set RuntimeExceptionMapper to debug to get the full stacktrace)", + requestContext, rootCauseClassName, rootCauseMessage); + } else { + LOGGER.error( + "Internal server error on {} - Root cause: {} - {} (Set RuntimeExceptionMapper in debug to get the full stacktrace)", + requestContext, rootCauseClassName, rootCauseMessage, exception); + } + LOGGER.debug("Full exception details for request: {}", requestContext, exception); + + return internalServerErrorResponse(); } } diff --git a/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java b/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java new file mode 100644 index 000000000..bfceaaea4 --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/exception/LogSanitizerTest.java @@ -0,0 +1,84 @@ +/* + * 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.rest.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link LogSanitizer}, guarding the log-injection defenses shared by the exception mappers. + */ +class LogSanitizerTest { + + @Test + void forLogging_replacesControlCharacters() { + assertEquals("a_b", LogSanitizer.forLogging("a\nb")); + assertEquals("a_b", LogSanitizer.forLogging("a\rb")); + assertEquals("a_b", LogSanitizer.forLogging("a\tb")); + } + + @Test + void forLogging_replacesLogFormatMarkers() { + assertEquals("a_b_c_d_e_", LogSanitizer.forLogging("a{b}c%d$e\\")); + } + + @Test + void forLogging_keepsPlainText() { + assertEquals("GET /context.json", LogSanitizer.forLogging("GET /context.json")); + } + + @Test + void forLogging_handlesNull() { + assertEquals("", LogSanitizer.forLogging(null)); + } + + @Test + void url_handlesNullAndTruncatesLongValues() { + assertEquals("null", LogSanitizer.url(null)); + String longUrl = repeat("a", 600); + String sanitized = LogSanitizer.url(longUrl); + assertTrue(sanitized.endsWith("...[truncated]"), "Long URL should be truncated: " + sanitized); + assertTrue(sanitized.length() < longUrl.length()); + } + + @Test + void httpMethod_normalizesAndWhitelists() { + assertEquals("GET", LogSanitizer.httpMethod("get")); + assertEquals("POST", LogSanitizer.httpMethod("post")); + assertEquals("UNKNOWN", LogSanitizer.httpMethod(null)); + assertEquals("UNKNOWN", LogSanitizer.httpMethod("")); + // Non-standard methods are still sanitized but not blindly trusted. + assertEquals("WEIRD", LogSanitizer.httpMethod("weird")); + } + + @Test + void className_keepsValidIdentifiersAndStripsTheRest() { + assertEquals("com.example.Foo$Bar", LogSanitizer.className("com.example.Foo$Bar")); + assertEquals("bad_name_", LogSanitizer.className("bad name!")); + assertEquals("Unknown", LogSanitizer.className(null)); + } + + private static String repeat(String s, int times) { + StringBuilder sb = new StringBuilder(s.length() * times); + for (int i = 0; i < times; i++) { + sb.append(s); + } + return sb.toString(); + } +} 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 new file mode 100644 index 000000000..8594a89c7 --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java @@ -0,0 +1,89 @@ +/* + * 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.rest.exception; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import org.junit.jupiter.api.Test; + +import javax.ws.rs.InternalServerErrorException; +import javax.ws.rs.core.Response; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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. + */ +class RestExceptionMapperTest { + + @Test + void jsonMappingException_mapsToBadRequest() { + Response response = new JsonMappingExceptionMapper() + .toResponse(JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, "cannot deserialize")); + assertErrorResponse(response, 400, "badRequest"); + } + + @Test + void runtimeException_mapsToInternalServerError() { + Response response = new RuntimeExceptionMapper().toResponse(new RuntimeException("boom")); + assertErrorResponse(response, 500, "internalServerError"); + } + + @Test + void runtimeException_withJsonCause_stillMapsToInternalServerError() { + // RuntimeExceptionMapper only downgrades the log level for JSON causes; the response stays 500. + RuntimeException exception = new RuntimeException( + JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, "cannot deserialize")); + Response response = new RuntimeExceptionMapper().toResponse(exception); + assertErrorResponse(response, 500, "internalServerError"); + } + + @Test + void internalServerError_withJsonMappingCause_mapsToBadRequest() { + InternalServerErrorException exception = new InternalServerErrorException("wrapped", + JsonMappingException.from((com.fasterxml.jackson.core.JsonParser) null, "cannot deserialize")); + Response response = new InternalServerErrorExceptionMapper().toResponse(exception); + assertErrorResponse(response, 400, "badRequest"); + } + + @Test + void internalServerError_withJsonParseCause_mapsToBadRequest() { + InternalServerErrorException exception = new InternalServerErrorException("wrapped", + new JsonParseException(null, "malformed")); + Response response = new InternalServerErrorExceptionMapper().toResponse(exception); + assertErrorResponse(response, 400, "badRequest"); + } + + @Test + void internalServerError_withNonJsonCause_mapsToInternalServerError() { + InternalServerErrorException exception = new InternalServerErrorException("kaboom", + new IllegalStateException("server fault")); + Response response = new InternalServerErrorExceptionMapper().toResponse(exception); + assertErrorResponse(response, 500, "internalServerError"); + } + + private static void assertErrorResponse(Response response, int expectedStatus, String expectedErrorMessage) { + assertEquals(expectedStatus, response.getStatus()); + Object entity = response.getEntity(); + assertTrue(entity instanceof Map, "Expected a Map entity but was: " + entity); + assertEquals(expectedErrorMessage, ((Map<?, ?>) entity).get("errorMessage")); + } +}
