Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions itests/src/test/java/org/apache/unomi/itests/AllITs.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
CopyPropertiesActionIT.class,
IncrementPropertyIT.class,
InputValidationIT.class,
RestCreateValidationIT.class,
ModifyConsentIT.class,
PatchIT.class,
ContextServletIT.class,
Expand Down
103 changes: 103 additions & 0 deletions itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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<String, String> 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<String, String> 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<String, String> headers) throws IOException {
CloseableHttpResponse response;
if (headers != null && !headers.isEmpty()) {
for (Map.Entry<String, String> 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<String, String> headers, String entityResourcePath, int expectedHTTPStatusCode, String expectedErrorMessage) throws Exception {
// test old servlets
performGETRequestTest(getFullUrl(uri), headers, entityResourcePath, expectedHTTPStatusCode, expectedErrorMessage);
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"sessionId": "dummy-session-id",
"events": [
{
"eventType": "view",
"source": {}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"sessionId": "dummy-session-id",
"events": [
{
"eventType": "view",
"target": {}
}
]
}
18 changes: 18 additions & 0 deletions itests/src/test/resources/validation/rule_invalidCondition.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
21 changes: 21 additions & 0 deletions itests/src/test/resources/validation/rule_valid.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
13 changes: 13 additions & 0 deletions itests/src/test/resources/validation/segment_invalidCondition.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"metadata": {
"id": "it-validation-invalid-segment",
"name": "Invalid segment IT",
"scope": "systemscope"
},
"condition": {
"type": "fakeConditionId",
"parameterValues": {
"param": "param value"
}
}
}
12 changes: 12 additions & 0 deletions itests/src/test/resources/validation/segment_valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"metadata": {
"id": "it-validation-valid-segment",
"name": "Valid segment IT",
"scope": "systemscope",
"enabled": false
},
"condition": {
"type": "matchAllCondition",
"parameterValues": {}
}
}
Loading