diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java b/itests/src/test/java/org/apache/unomi/itests/AllITs.java index ace015dfb0..3ea7dd3400 100644 --- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java @@ -51,6 +51,7 @@ CopyPropertiesActionIT.class, IncrementPropertyIT.class, InputValidationIT.class, + RestCreateValidationIT.class, ModifyConsentIT.class, PatchIT.class, ContextServletIT.class, diff --git a/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java b/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java index 316ca53540..dd9ac69a7e 100644 --- a/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java @@ -16,6 +16,7 @@ */ package org.apache.unomi.itests; +import com.fasterxml.jackson.databind.JsonNode; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; @@ -42,6 +43,7 @@ import java.util.Objects; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; @RunWith(PaxExam.class) @@ -235,6 +237,107 @@ public void test_cookie_profileIdPattern() throws Exception { doGETRequestTest(CONTEXT_JS_URL, headers, null, 200, null); } + + /** + * UNOMI-933: unparseable context request bodies on {@code /context.json} and {@code /context.js} + * must return 400, not 500. Deserialization failures return JSON {@code {"errorMessage":"badRequest"}}; + * schema-level validation returns plain text. + */ + @Test + public void test_contextRequest_garbageBody() throws Exception { + // test JSON endpoint (and its /cxs/ mirror via doPOSTRawBodyTestBadRequest) + doPOSTRawBodyTestBadRequest(CONTEXT_JSON_URL + "?sessionId=dummy-session-id", "foo"); + // test JS endpoint (and its /cxs/ mirror) + doPOSTRawBodyTestBadRequest(CONTEXT_JS_URL + "?sessionId=dummy-session-id", "foo"); + } + + @Test + public void test_contextRequest_eventEmptySource() throws Exception { + doPOSTRequestTestBadRequest(CONTEXT_JSON_URL, null, "/validation/contextRequest_eventEmptySource.json"); + doGETRequestTestBadRequest(CONTEXT_JSON_URL, "/validation/contextRequest_eventEmptySource.json"); + } + + @Test + public void test_contextRequest_eventEmptyTarget() throws Exception { + doPOSTRequestTestBadRequest(CONTEXT_JSON_URL, null, "/validation/contextRequest_eventEmptyTarget.json"); + doGETRequestTestBadRequest(CONTEXT_JSON_URL, "/validation/contextRequest_eventEmptyTarget.json"); + } + + private void doPOSTRawBodyTestBadRequest(String uri, String rawBody) throws Exception { + // test old servlets + performPOSTRawBodyTestBadRequest(getFullUrl(uri), rawBody); + // test directly CXS endpoints + performPOSTRawBodyTestBadRequest(getFullUrl("/cxs" + uri), rawBody); + } + + private void doGETRequestTestBadRequest(String uri, String entityResourcePath) throws Exception { + // test old servlets + performGETRequestTestBadRequest(getFullUrl(uri), entityResourcePath); + // test directly CXS endpoints + performGETRequestTestBadRequest(getFullUrl("/cxs" + uri), entityResourcePath); + } + + private void performGETRequestTestBadRequest(String url, String entityResourcePath) throws Exception { + if (entityResourcePath != null) { + String payload = getValidatedBundleJSON(entityResourcePath, new HashMap<>()); + url += (url.contains("?") ? "&" : "?") + "payload=" + URLEncoder.encode(payload, StandardCharsets.UTF_8.toString()); + } + performRequestExpectBadRequest(new HttpGet(url), null); + } + + private void doPOSTRequestTestBadRequest(String uri, Map headers, String entityResourcePath) + throws Exception { + performPOSTRequestTestBadRequest(getFullUrl(uri), headers, entityResourcePath); + performPOSTRequestTestBadRequest(getFullUrl("/cxs" + uri), headers, entityResourcePath); + } + + private void performPOSTRawBodyTestBadRequest(String url, String rawBody) throws IOException { + HttpPost request = new HttpPost(url); + request.setHeader("Content-Type", "application/json"); + request.setEntity(new StringEntity(rawBody, ContentType.APPLICATION_JSON)); + performRequestExpectBadRequest(request, null); + } + + private void performPOSTRequestTestBadRequest(String url, Map headers, String entityResourcePath) + throws IOException { + HttpPost request = new HttpPost(url); + if (headers == null) { + headers = new HashMap<>(); + } + headers.put("Content-Type", "application/json"); + if (entityResourcePath != null) { + request.setEntity(new StringEntity(getValidatedBundleJSON(entityResourcePath, new HashMap<>()), ContentType.create("application/json"))); + } + performRequestExpectBadRequest(request, headers); + } + + private void performRequestExpectBadRequest(HttpUriRequest request, Map headers) throws IOException { + CloseableHttpResponse response; + if (headers != null && !headers.isEmpty()) { + for (Map.Entry headerEntry : headers.entrySet()) { + request.addHeader(headerEntry.getKey(), headerEntry.getValue()); + } + } + try { + response = HttpClientThatWaitsForUnomi.doRequest(request, 400); + } catch (Exception e) { + fail("Something went wrong with the request to Unomi that is unexpected: " + e.getMessage()); + return; + } + assertEquals("Invalid response code", 400, response.getStatusLine().getStatusCode()); + assertInvalidClientDataResponse(EntityUtils.toString(response.getEntity())); + } + + private void assertInvalidClientDataResponse(String responseBody) throws IOException { + if (ERROR_MESSAGE_INVALID_DATA_RECEIVED.equals(responseBody)) { + return; + } + JsonNode json = objectMapper.readTree(responseBody); + JsonNode errorNode = json.get("errorMessage"); + assertNotNull("Response JSON missing 'errorMessage' field. Body: " + responseBody, errorNode); + assertEquals("badRequest", errorNode.asText()); + } + private void doGETRequestTest(String uri, Map headers, String entityResourcePath, int expectedHTTPStatusCode, String expectedErrorMessage) throws Exception { // test old servlets performGETRequestTest(getFullUrl(uri), headers, entityResourcePath, expectedHTTPStatusCode, expectedErrorMessage); diff --git a/itests/src/test/java/org/apache/unomi/itests/RestCreateValidationIT.java b/itests/src/test/java/org/apache/unomi/itests/RestCreateValidationIT.java new file mode 100644 index 0000000000..cff021b3a3 --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/RestCreateValidationIT.java @@ -0,0 +1,138 @@ +/* + * 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.itests; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.segments.Segment; +import org.apache.unomi.itests.tools.LogChecker; +import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerSuite; + +import java.io.IOException; +import java.util.HashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +/** + * Integration tests for REST create endpoints returning HTTP 400 on invalid payloads + * (UNOMI-934 rules, UNOMI-935 segments). Verifies both the HTTP status code and the + * JSON response contract: {@code {"errorMessage":"badRequest"}}. + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerSuite.class) +public class RestCreateValidationIT extends BaseIT { + + private static final String RULES_URL = "/cxs/rules"; + private static final String SEGMENTS_URL = "/cxs/segments"; + + @Override + protected LogChecker createLogChecker() { + return LogChecker.builder() + .addIgnoredSubstring("Response status code: 400") + .addIgnoredSubstring("Invalid rule condition") + .addIgnoredSubstring("BadSegmentConditionException") + .addIgnoredSubstring("IllegalArgumentExceptionMapper") + .addIgnoredSubstring("BadSegmentConditionExceptionMapper") + .addIgnoredSubstring("JsonMappingExceptionMapper") + // RuntimeExceptionMapper logs "Bad request on ..." at WARN for client errors + .addIgnoredSubstring("Bad request on") + .build(); + } + + @After + public void tearDown() throws InterruptedException { + removeItems(Rule.class, Segment.class); + } + + @Test + public void testCreateValidRule_returns204() throws Exception { + doPostRawBodyTest(RULES_URL, getValidatedBundleJSON("/validation/rule_valid.json", new HashMap<>()), 204); + } + + @Test + public void testCreateValidSegment_returns204() throws Exception { + doPostRawBodyTest(SEGMENTS_URL, getValidatedBundleJSON("/validation/segment_valid.json", new HashMap<>()), 204); + } + + @Test + public void testCreateRuleWithInvalidCondition_returnsBadRequest() throws Exception { + doPostRawBodyTest(RULES_URL, getValidatedBundleJSON("/validation/rule_invalidCondition.json", new HashMap<>()), 400); + } + + @Test + public void testCreateRuleWithMalformedJson_returnsBadRequest() throws Exception { + doPostRawBodyTest(RULES_URL, "foo", 400); + } + + @Test + public void testCreateSegmentWithInvalidCondition_returnsBadRequest() throws Exception { + doPostRawBodyTest(SEGMENTS_URL, getValidatedBundleJSON("/validation/segment_invalidCondition.json", new HashMap<>()), 400); + } + + @Test + public void testCreateSegmentWithMalformedJson_returnsBadRequest() throws Exception { + doPostRawBodyTest(SEGMENTS_URL, "foo", 400); + } + + private void doPostRawBodyTest(String uri, String body, int expectedStatusCode) throws Exception { + performRestPostTest(getFullUrl(uri), body, expectedStatusCode); + } + + private void performRestPostTest(String url, String body, int expectedStatusCode) throws IOException { + HttpPost request = new HttpPost(url); + request.setHeader("Content-Type", "application/json"); + request.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON)); + CloseableHttpResponse response; + try { + response = HttpClientThatWaitsForUnomi.doRequest(request, expectedStatusCode); + } catch (Exception e) { + fail("Request to " + url + " failed unexpectedly: " + e.getMessage()); + return; + } + assertEquals("Unexpected HTTP status for POST " + url, expectedStatusCode, response.getStatusLine().getStatusCode()); + if (expectedStatusCode == 400) { + assertBadRequestResponse(url, EntityUtils.toString(response.getEntity())); + } + } + + private void assertBadRequestResponse(String url, String responseBody) throws IOException { + assertNotNull("Expected a response body for 400 on POST " + url, responseBody); + JsonNode json; + try { + json = objectMapper.readTree(responseBody); + } catch (Exception e) { + fail("Expected JSON in 400 response for POST " + url + " but got: " + responseBody); + return; + } + JsonNode errorNode = json.get("errorMessage"); + assertNotNull("Response JSON missing 'errorMessage' field for POST " + url + ". Body: " + responseBody, errorNode); + assertEquals("badRequest", errorNode.asText()); + } +} diff --git a/itests/src/test/resources/validation/contextRequest_eventEmptySource.json b/itests/src/test/resources/validation/contextRequest_eventEmptySource.json new file mode 100644 index 0000000000..725762e642 --- /dev/null +++ b/itests/src/test/resources/validation/contextRequest_eventEmptySource.json @@ -0,0 +1,9 @@ +{ + "sessionId": "dummy-session-id", + "events": [ + { + "eventType": "view", + "source": {} + } + ] +} diff --git a/itests/src/test/resources/validation/contextRequest_eventEmptyTarget.json b/itests/src/test/resources/validation/contextRequest_eventEmptyTarget.json new file mode 100644 index 0000000000..1e1ff7541f --- /dev/null +++ b/itests/src/test/resources/validation/contextRequest_eventEmptyTarget.json @@ -0,0 +1,9 @@ +{ + "sessionId": "dummy-session-id", + "events": [ + { + "eventType": "view", + "target": {} + } + ] +} diff --git a/itests/src/test/resources/validation/rule_invalidCondition.json b/itests/src/test/resources/validation/rule_invalidCondition.json new file mode 100644 index 0000000000..e5bf459ba5 --- /dev/null +++ b/itests/src/test/resources/validation/rule_invalidCondition.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "id": "it-validation-invalid-rule", + "name": "Invalid rule IT", + "scope": "systemscope", + "enabled": true + }, + "condition": {}, + "actions": [ + { + "type": "setPropertyAction", + "parameterValues": { + "setPropertyName": "properties.itValidation", + "setPropertyValue": "test" + } + } + ] +} diff --git a/itests/src/test/resources/validation/rule_valid.json b/itests/src/test/resources/validation/rule_valid.json new file mode 100644 index 0000000000..02e5b12c8b --- /dev/null +++ b/itests/src/test/resources/validation/rule_valid.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "id": "it-validation-valid-rule", + "name": "Valid rule IT", + "scope": "systemscope", + "enabled": false + }, + "condition": { + "type": "matchAllCondition", + "parameterValues": {} + }, + "actions": [ + { + "type": "setPropertyAction", + "parameterValues": { + "setPropertyName": "properties.itValidation", + "setPropertyValue": "test" + } + } + ] +} diff --git a/itests/src/test/resources/validation/segment_invalidCondition.json b/itests/src/test/resources/validation/segment_invalidCondition.json new file mode 100644 index 0000000000..d184f0fe8f --- /dev/null +++ b/itests/src/test/resources/validation/segment_invalidCondition.json @@ -0,0 +1,13 @@ +{ + "metadata": { + "id": "it-validation-invalid-segment", + "name": "Invalid segment IT", + "scope": "systemscope" + }, + "condition": { + "type": "fakeConditionId", + "parameterValues": { + "param": "param value" + } + } +} diff --git a/itests/src/test/resources/validation/segment_valid.json b/itests/src/test/resources/validation/segment_valid.json new file mode 100644 index 0000000000..a02f49f630 --- /dev/null +++ b/itests/src/test/resources/validation/segment_valid.json @@ -0,0 +1,12 @@ +{ + "metadata": { + "id": "it-validation-valid-segment", + "name": "Valid segment IT", + "scope": "systemscope", + "enabled": false + }, + "condition": { + "type": "matchAllCondition", + "parameterValues": {} + } +}