This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 0aa36bc7f UNOMI-139 UNOMI-878: Platform integration tests (#766)
0aa36bc7f is described below
commit 0aa36bc7f581d50471424c26588d4261f4804942
Author: Serge Huber <[email protected]>
AuthorDate: Wed Jun 10 10:48:41 2026 +0200
UNOMI-139 UNOMI-878: Platform integration tests (#766)
---
.../test/java/org/apache/unomi/itests/AllITs.java | 7 +-
.../org/apache/unomi/itests/EventsCollectorIT.java | 130 +++++
.../java/org/apache/unomi/itests/PersonaIT.java | 159 ++++++
.../java/org/apache/unomi/itests/SchedulerIT.java | 158 ++++++
.../java/org/apache/unomi/itests/SecurityIT.java | 81 +++
.../java/org/apache/unomi/itests/TenantIT.java | 611 +++++++++++++++++++++
.../persona/persona-with-sessions-payload.json | 36 ++
.../unomi/rest/models/EventCollectorResponse.java | 5 -
8 files changed, 1181 insertions(+), 6 deletions(-)
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 f90a2e8e7..b450b3310 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -38,6 +38,7 @@ import org.junit.runners.Suite.SuiteClasses;
ConditionQueryBuilderIT.class,
SegmentIT.class,
ProfileServiceIT.class,
+ PersonaIT.class,
ProfileImportBasicIT.class,
ProfileImportSurfersIT.class,
ProfileImportRankingIT.class,
@@ -52,6 +53,7 @@ import org.junit.runners.Suite.SuiteClasses;
ModifyConsentIT.class,
PatchIT.class,
ContextServletIT.class,
+ SecurityIT.class,
RuleServiceIT.class,
PrivacyServiceIT.class,
GroovyActionsServiceIT.class,
@@ -73,8 +75,11 @@ import org.junit.runners.Suite.SuiteClasses;
TenantCommandsIT.class,
RuleStatisticsCommandsIT.class,
OtherCommandsIT.class,
- HealthCheckIT.class,
LegacyQueryBuilderMappingIT.class,
+ TenantIT.class,
+ SchedulerIT.class,
+ EventsCollectorIT.class,
+ HealthCheckIT.class
})
public class AllITs {
}
diff --git
a/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
new file mode 100644
index 000000000..79971ef05
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
@@ -0,0 +1,130 @@
+/*
+ * 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 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.Event;
+import org.apache.unomi.api.EventsCollectorRequest;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
+import org.apache.unomi.rest.models.EventCollectorResponse;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+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.util.Collections;
+import java.util.Objects;
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class EventsCollectorIT extends BaseIT {
+ private final static String EVENTS_URL = "/cxs/eventcollector";
+ private final static String TEST_EVENT_TYPE = "testEventType";
+ private final static String TEST_PROFILE_ID = "test-profile-id";
+ private final static String TEST_SESSION_ID = "test-session-id";
+ private final static String TEST_SCOPE = "testScope";
+ private Profile profile;
+
+ @Before
+ public void setUp() throws InterruptedException {
+ profile = new Profile(TEST_PROFILE_ID);
+ profileService.save(profile);
+
+ keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required
time", () -> profileService.load(TEST_PROFILE_ID),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT,
DEFAULT_TRYING_TRIES);
+
+ // create schemas required for tests
+
schemaService.saveSchema(resourceAsString("schemas/events/test-event-type.json"));
+ keepTrying("Couldn't find json schemas",
+ () -> schemaService.getInstalledJsonSchemaIds(),
+ (schemaIds) ->
schemaIds.contains("https://unomi.apache.org/schemas/json/events/testEventType/1-0-0"),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService);
+ keepTrying("Scope test-scope not found in the required time", () ->
scopeService.getScope(TEST_SCOPE),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT,
DEFAULT_TRYING_TRIES);
+ }
+
+ @After
+ public void tearDown() throws InterruptedException {
+ persistenceService.refresh();
+ TestUtils.removeAllEvents(definitionsService, persistenceService);
+ TestUtils.removeAllSessions(definitionsService, persistenceService);
+ TestUtils.removeAllProfiles(definitionsService, persistenceService);
+ profileService.delete(profile.getItemId(), false);
+
+ // cleanup schemas
+
schemaService.deleteSchema("https://unomi.apache.org/schemas/json/events/testEventType/1-0-0");
+ keepTrying("Should not find json schemas anymore",
+ () -> schemaService.getInstalledJsonSchemaIds(),
+ (schemaIds) ->
(!schemaIds.contains("https://unomi.apache.org/schemas/json/events/testEventType/1-0-0")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ scopeService.delete(TEST_SCOPE);
+ }
+
+ @Test
+ public void testEventsCollectorWithPublicApiKey() throws Exception {
+
+ // Create event and request
+ Event event = new Event();
+ event.setEventType(TEST_EVENT_TYPE);
+ event.setScope(TEST_SCOPE);
+ event.setSessionId(TEST_SESSION_ID);
+ event.setProfileId(TEST_PROFILE_ID);
+
+ EventsCollectorRequest eventsCollectorRequest = new
EventsCollectorRequest();
+ eventsCollectorRequest.setSessionId(TEST_SESSION_ID);
+ eventsCollectorRequest.setEvents(Collections.singletonList(event));
+
+ // Send request with public API key
+ HttpPost request = new HttpPost(getFullUrl(EVENTS_URL));
+ request.addHeader("Content-Type", "application/json");
+ String requestBody =
objectMapper.writeValueAsString(eventsCollectorRequest);
+ request.setEntity(new StringEntity(requestBody,
ContentType.APPLICATION_JSON));
+
+ // Execute request and verify response
+ try (CloseableHttpResponse response =
HttpClientThatWaitsForUnomi.doRequest(request, 200)) {
+ String responseContent =
EntityUtils.toString(response.getEntity());
+ EventCollectorResponse eventResponse =
objectMapper.readValue(responseContent, EventCollectorResponse.class);
+ Assert.assertNotNull("Event collector response should not be
null", eventResponse);
+
+ // Check that the response indicates the session and profile were
updated
+ int expectedFlags = EventService.PROFILE_UPDATED |
EventService.SESSION_UPDATED;
+ Assert.assertEquals("Response should indicate that the session and
profile were updated",
+ expectedFlags, eventResponse.getUpdated());
+ }
+
+ // Test with invalid API key — remove auth set by the previous
request, clear tenant context
+ request.removeHeaders("X-Unomi-Api-Key");
+ HttpClientThatWaitsForUnomi.setTestTenant(null, null, null);
+ try (CloseableHttpResponse response =
HttpClientThatWaitsForUnomi.doRequest(request, 401)) {
+ Assert.assertEquals("Request with invalid API key should return
401", 401, response.getStatusLine().getStatusCode());
+ }
+ }
+
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/PersonaIT.java
b/itests/src/test/java/org/apache/unomi/itests/PersonaIT.java
new file mode 100644
index 000000000..a5754bcbe
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/PersonaIT.java
@@ -0,0 +1,159 @@
+/*
+ * 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.core.type.TypeReference;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.PersonaSession;
+import org.apache.unomi.api.PersonaWithSessions;
+import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.unomi.api.Session;
+
+import java.util.Objects;
+
+/**
+ * Integration tests for persona functionality.
+ * This test class covers persona-related features including persona sessions.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class PersonaIT extends BaseIT {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(PersonaIT.class);
+
+ private static final String BASE_PROFILES_PATH = "/cxs/profiles";
+ private static final String PERSONA_WITH_SESSIONS_ENDPOINT =
BASE_PROFILES_PATH + "/personasWithSessions";
+ private static final String PERSONA_ENDPOINT = BASE_PROFILES_PATH +
"/personas";
+ private static final String PERSONA_BY_ID_ENDPOINT = PERSONA_ENDPOINT +
"/{personaId}";
+ private static final String PERSONA_SESSIONS_ENDPOINT = PERSONA_ENDPOINT +
"/{personaId}/sessions";
+
+ private static final String TEST_PERSONA_ID = "test-persona-with-sessions";
+ private static final String TEST_SESSION_ID = "test-session-1";
+ private static final String PAYLOAD_RESOURCE =
"persona/persona-with-sessions-payload.json";
+
+ @Before
+ public void setUp() throws InterruptedException {
+ // Wait for persona REST endpoint to be available
+ // Using GET /personas/{personaId} with a dummy ID to check endpoint
availability
+ String checkEndpoint = PERSONA_BY_ID_ENDPOINT.replace("{personaId}",
"endpoint-check");
+ keepTrying("Couldn't find persona endpoint", () -> {
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(checkEndpoint)), AuthType.JAAS_ADMIN)) {
+ int statusCode = response.getStatusLine().getStatusCode();
+ return (statusCode == 200 || statusCode == 204 || statusCode
== 404) ? Boolean.TRUE : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }, Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ }
+
+ @After
+ public void tearDown() {
+ try {
+ persistenceService.remove(TEST_SESSION_ID, Session.class);
+ } catch (Exception e) {
+ LOGGER.warn("Failed to clean up test session: {}", e.getMessage());
+ }
+ try {
+ profileService.delete(TEST_PERSONA_ID, true);
+ } catch (Exception e) {
+ LOGGER.warn("Failed to clean up test persona: {}", e.getMessage());
+ }
+ }
+
+ @Test
+ public void testSavePersonaWithSessionsAndRetrieveSessions() throws
Exception {
+ // Create persona with sessions via REST API
+ HttpPost createRequest = new
HttpPost(getFullUrl(PERSONA_WITH_SESSIONS_ENDPOINT));
+ createRequest.setEntity(new
StringEntity(resourceAsString(PAYLOAD_RESOURCE), JSON_CONTENT_TYPE));
+
+ PersonaWithSessions createdPersona;
+ try (CloseableHttpResponse createResponse =
executeHttpRequest(createRequest, AuthType.JAAS_ADMIN)) {
+ int statusCode = createResponse.getStatusLine().getStatusCode();
+ Assert.assertEquals("Persona creation should return 200 OK", 200,
statusCode);
+
+ String responseBody =
EntityUtils.toString(createResponse.getEntity());
+ createdPersona =
CustomObjectMapper.getObjectMapper().readValue(responseBody,
PersonaWithSessions.class);
+ }
+
+ Assert.assertNotNull("Created persona should not be null",
createdPersona);
+ Assert.assertNotNull("Created persona should have persona object",
createdPersona.getPersona());
+ Assert.assertEquals("Persona ID should match", TEST_PERSONA_ID,
createdPersona.getPersona().getItemId());
+ Assert.assertNotNull("Created persona should have sessions",
createdPersona.getSessions());
+ Assert.assertFalse("Created persona should have at least one session",
createdPersona.getSessions().isEmpty());
+
+ // Wait for persona's sessions to be indexed before testing session
retrieval
+ // This ensures the sessions are properly linked and queryable
+ String sessionsUrl = PERSONA_SESSIONS_ENDPOINT.replace("{personaId}",
TEST_PERSONA_ID);
+ PartialList<PersonaSession> sessions = keepTrying(
+ "Persona sessions should be retrievable after creation",
+ () -> {
+ try {
+ try (CloseableHttpResponse response =
executeHttpRequest(new HttpGet(getFullUrl(sessionsUrl)), AuthType.JAAS_ADMIN)) {
+ if (response.getStatusLine().getStatusCode() == 200) {
+ String responseBody =
EntityUtils.toString(response.getEntity());
+ PartialList<PersonaSession> result =
CustomObjectMapper.getObjectMapper().readValue(
+ responseBody, new
TypeReference<PartialList<PersonaSession>>() {});
+ // Check if the test session is present
+ if (result != null && result.getList() != null &&
!result.getList().isEmpty()) {
+ boolean hasTestSession =
result.getList().stream()
+ .anyMatch(session ->
TEST_SESSION_ID.equals(session.getItemId()));
+ return hasTestSession ? result : null;
+ }
+ }
+ return null;
+ }
+ } catch (Exception e) {
+ LOGGER.debug("Error retrieving persona sessions: {}",
e.getMessage());
+ return null;
+ }
+ },
+ Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT,
+ DEFAULT_TRYING_TRIES * 2 // sessions are indexed asynchronously
after persona creation; double retries avoid flakiness
+ );
+
+ Assert.assertNotNull("Persona sessions should be retrievable",
sessions);
+ Assert.assertNotNull("Sessions list should not be null",
sessions.getList());
+ Assert.assertFalse("Sessions list should not be empty",
sessions.getList().isEmpty());
+
+ // Verify the test session is present and properly linked
+ PersonaSession testSession = sessions.getList().stream()
+ .filter(session -> TEST_SESSION_ID.equals(session.getItemId()))
+ .findFirst()
+ .orElse(null);
+
+ Assert.assertNotNull("Test session should be found in retrieved
sessions", testSession);
+ Assert.assertNotNull("Session should have a profile reference",
testSession.getProfile());
+ Assert.assertEquals("Session should be linked to the correct persona",
TEST_PERSONA_ID, testSession.getProfile().getItemId());
+ }
+}
+
diff --git a/itests/src/test/java/org/apache/unomi/itests/SchedulerIT.java
b/itests/src/test/java/org/apache/unomi/itests/SchedulerIT.java
new file mode 100644
index 000000000..f5d98681b
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/SchedulerIT.java
@@ -0,0 +1,158 @@
+/*
+ * 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 org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.tasks.ScheduledTask;
+import org.apache.unomi.api.tasks.TaskExecutor;
+import org.junit.After;
+import org.junit.Before;
+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.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.*;
+
+/**
+ * Integration tests for the Scheduler REST API
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class SchedulerIT extends BaseIT {
+
+ private final static String TEST_TASK_TYPE = "test-task";
+ private String testTaskId;
+
+ @Before
+ public void setUp() {
+ // Register a test task executor
+ TestTaskExecutor executor = new TestTaskExecutor();
+ schedulerService.registerTaskExecutor(executor);
+
+ // Create a test task
+ Map<String, Object> parameters = new HashMap<>();
+ parameters.put("testParam", "testValue");
+
+ ScheduledTask task = schedulerService.createTask(
+ TEST_TASK_TYPE,
+ parameters,
+ 0,
+ 1000,
+ TimeUnit.MILLISECONDS,
+ true,
+ false,
+ false,
+ true
+ );
+ testTaskId = task.getItemId();
+ schedulerService.scheduleTask(task);
+ }
+
+ @After
+ public void tearDown() {
+ if (testTaskId != null) {
+ try {
+ schedulerService.cancelTask(testTaskId);
+ } catch (Exception e) {
+ // Ignore cleanup errors
+ }
+ }
+ }
+
+ @Test
+ public void testGetTasks() throws Exception {
+ // Test getting all tasks
+ PartialList<ScheduledTask> tasks = get("/cxs/tasks",
PartialList.class);
+ assertNotNull("Tasks list should not be null", tasks);
+ assertTrue("Should have at least one task", tasks.getList().size() >
0);
+
+ // Test filtering by status
+ tasks = get("/cxs/tasks?status=SCHEDULED", PartialList.class);
+ assertNotNull("Filtered tasks list should not be null", tasks);
+
+ // Test filtering by type
+ tasks = get("/cxs/tasks?type=" + TEST_TASK_TYPE, PartialList.class);
+ assertNotNull("Type-filtered tasks list should not be null", tasks);
+ assertTrue("Should find test task", tasks.getList().size() > 0);
+ }
+
+ @Test
+ public void testGetTask() throws Exception {
+ ScheduledTask task = get("/cxs/tasks/" + testTaskId,
ScheduledTask.class);
+ assertNotNull("Task should not be null", task);
+ assertEquals("Task ID should match", testTaskId, task.getItemId());
+ assertEquals("Task type should match", TEST_TASK_TYPE,
task.getTaskType());
+ }
+
+ @Test
+ public void testGetNonExistentTask() throws Exception {
+ ScheduledTask task = get("/cxs/tasks/non-existent-task",
ScheduledTask.class);
+ assertNull("Task should be null", task);
+ }
+
+ @Test
+ public void testCancelTask() throws Exception {
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpDelete(getFullUrl("/cxs/tasks/" + testTaskId)))) {
+ assertEquals("Response should be No Content", 204,
response.getStatusLine().getStatusCode());
+ }
+
+ // Verify task is cancelled
+ ScheduledTask task = schedulerService.getTask(testTaskId);
+ assertEquals("Task should be cancelled",
ScheduledTask.TaskStatus.CANCELLED, task.getStatus());
+ }
+
+ @Test
+ public void testRetryTask() throws Exception {
+ // Drive the task to FAILED state directly — the test node may not be
the scheduler
+ // executor node, so we cannot rely on the executor running organically
+ ScheduledTask task = schedulerService.getTask(testTaskId);
+ task.setStatus(ScheduledTask.TaskStatus.FAILED);
+ task.setLastError("forced failure for test");
+ task.setFailureCount(1);
+ schedulerService.saveTask(task);
+
+ try (CloseableHttpResponse response = post("/cxs/tasks/" + testTaskId
+ "/retry?resetFailureCount=true", null)) {
+ assertEquals("Response should be OK", 200,
response.getStatusLine().getStatusCode());
+ String responseBody = EntityUtils.toString(response.getEntity());
+ ScheduledTask retried = objectMapper.readValue(responseBody,
ScheduledTask.class);
+ assertNotNull("Task should not be null", retried);
+ assertEquals("Task should be scheduled",
ScheduledTask.TaskStatus.SCHEDULED, retried.getStatus());
+ assertEquals("Failure count should be reset", 0,
retried.getFailureCount());
+ }
+ }
+
+ private static class TestTaskExecutor implements TaskExecutor {
+ @Override
+ public String getTaskType() {
+ return TEST_TASK_TYPE;
+ }
+
+ @Override
+ public void execute(ScheduledTask task, TaskStatusCallback callback)
throws Exception {
+ callback.complete();
+ }
+ }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/SecurityIT.java
b/itests/src/test/java/org/apache/unomi/itests/SecurityIT.java
new file mode 100644
index 000000000..df222b61c
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/SecurityIT.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.itests;
+
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.ExecutionContextManager;
+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 javax.security.auth.Subject;
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class SecurityIT extends BaseIT {
+
+ @Test
+ public void testSystemOperationsAndContext() throws Exception {
+ SecurityService securityService =
getOsgiService(SecurityService.class);
+ ExecutionContextManager contextManager =
getOsgiService(ExecutionContextManager.class);
+
+ // Test system subject creation and validation
+ Subject systemSubject = securityService.getSystemSubject();
+ assertNotNull("System subject should not be null", systemSubject);
+
+ Set<String> roles =
securityService.extractRolesFromSubject(systemSubject);
+ assertTrue("System subject should have administrator role",
+ roles.contains(UnomiRoles.ADMINISTRATOR));
+
+ // Test system operation execution
+ String result = contextManager.executeAsSystem(() -> {
+ ExecutionContext ctx = contextManager.getCurrentContext();
+ assertNotNull("System execution context should not be null", ctx);
+ assertTrue("System context should have admin role",
+ ctx.hasRole(UnomiRoles.ADMINISTRATOR));
+ return "success";
+ });
+ assertEquals("System operation should execute successfully",
"success", result);
+
+ // Test context isolation
+ ExecutionContext regularContext = contextManager.getCurrentContext();
+ assertFalse("Regular context should not have admin role by default",
+ regularContext.hasRole(UnomiRoles.ADMINISTRATOR));
+
+ // Test error handling during system operation
+ try {
+ contextManager.executeAsSystem(() -> {
+ throw new RuntimeException("Test exception");
+ });
+ fail("Should throw exception from system operation");
+ } catch (RuntimeException e) {
+ assertEquals("Test exception", e.getMessage());
+ // Verify context is properly restored after exception
+ ExecutionContext postErrorContext =
contextManager.getCurrentContext();
+ assertEquals("Context should be restored after error",
+ regularContext.getTenantId(), postErrorContext.getTenantId());
+ }
+ }
+
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
new file mode 100644
index 000000000..bfdf693f1
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
@@ -0,0 +1,611 @@
+/*
+ * 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 org.apache.http.auth.AuthScope;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.query.Query;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.ResourceQuota;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import org.junit.Assert;
+import org.junit.Before;
+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 org.apache.http.util.EntityUtils;
+
+import java.util.*;
+import java.util.Base64;
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class TenantIT extends BaseIT {
+
+ private static final String REST_ENDPOINT = "/cxs/tenants";
+ private CustomObjectMapper objectMapper;
+
+ @Before
+ public void setUp() throws InterruptedException {
+ objectMapper = new CustomObjectMapper();
+
+ // Wait for tenant REST endpoint to be available
+ keepTrying("Couldn't find tenant endpoint", () -> {
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(REST_ENDPOINT)), AuthType.JAAS_ADMIN)) {
+ return response.getStatusLine().getStatusCode() == 200 ?
response : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }, Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ }
+
+ @Test
+ public void testRestEndpoint() throws Exception {
+ // Test create tenant
+ Map<String, Object> properties = new HashMap<>();
+ properties.put("testProperty", "testValue");
+
+ Map<String, Object> requestBody = new HashMap<>();
+ requestBody.put("requestedId", "rest-test-tenant");
+ requestBody.put("properties", properties);
+
+ HttpPost createRequest = new HttpPost(getFullUrl(REST_ENDPOINT));
+ createRequest.setEntity(new
StringEntity(objectMapper.writeValueAsString(requestBody),
ContentType.APPLICATION_JSON));
+
+ String createResponse;
+ Tenant createdTenant;
+ try (CloseableHttpResponse response =
executeHttpRequest(createRequest, AuthType.JAAS_ADMIN)) {
+ createResponse = EntityUtils.toString(response.getEntity());
+ createdTenant = objectMapper.readValue(createResponse,
Tenant.class);
+ }
+
+ Assert.assertNotNull("Created tenant should not be null",
createdTenant);
+ Assert.assertEquals("rest-test-tenant", createdTenant.getItemId());
+ Assert.assertNotNull("Tenant should have public API key",
createdTenant.getPublicApiKey());
+ Assert.assertNotNull("Tenant should have private API key",
createdTenant.getPrivateApiKey());
+
+ boolean tenantDeleted = false;
+ try {
+ // Test get tenant
+ String getResponse;
+ Tenant retrievedTenant;
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(REST_ENDPOINT + "/" + createdTenant.getItemId())),
AuthType.JAAS_ADMIN)) {
+ getResponse = EntityUtils.toString(response.getEntity());
+ retrievedTenant = objectMapper.readValue(getResponse,
Tenant.class);
+ }
+
+ Assert.assertEquals("Retrieved tenant should match created
tenant", createdTenant.getItemId(), retrievedTenant.getItemId());
+
+ // Test update tenant
+ retrievedTenant.setName("Updated Rest Test Tenant");
+ ResourceQuota quota = new ResourceQuota();
+ quota.setMaxProfiles(1000L);
+ quota.setMaxEvents(5000L);
+ retrievedTenant.setResourceQuota(quota);
+
+ HttpPut updateRequest = new HttpPut(getFullUrl(REST_ENDPOINT + "/"
+ retrievedTenant.getItemId()));
+ updateRequest.setEntity(new
StringEntity(objectMapper.writeValueAsString(retrievedTenant),
ContentType.APPLICATION_JSON));
+
+ String updateResponse;
+ Tenant updatedTenant;
+ try (CloseableHttpResponse response =
executeHttpRequest(updateRequest, AuthType.JAAS_ADMIN)) {
+ updateResponse = EntityUtils.toString(response.getEntity());
+ updatedTenant = objectMapper.readValue(updateResponse,
Tenant.class);
+ }
+
+ Assert.assertEquals("Tenant name should be updated", "Updated Rest
Test Tenant", updatedTenant.getName());
+ Assert.assertEquals("Tenant quota should be updated", (Long)
1000L, (Long) updatedTenant.getResourceQuota().getMaxProfiles());
+
+ // Test generate new API key
+ String generateKeyUrl =
String.format("%s/%s/apikeys?type=%s&validityDays=30",
+ getFullUrl(REST_ENDPOINT), updatedTenant.getItemId(),
ApiKey.ApiKeyType.PUBLIC.name());
+ HttpPost generateKeyRequest = new HttpPost(generateKeyUrl);
+
+ String generateKeyResponse;
+ ApiKey newApiKey;
+ try (CloseableHttpResponse response =
executeHttpRequest(generateKeyRequest, AuthType.JAAS_ADMIN)) {
+ generateKeyResponse =
EntityUtils.toString(response.getEntity());
+ newApiKey = objectMapper.readValue(generateKeyResponse,
ApiKey.class);
+ }
+
+ Assert.assertNotNull("New API key should not be null", newApiKey);
+ Assert.assertEquals("API key type should match requested type",
ApiKey.ApiKeyType.PUBLIC, newApiKey.getKeyType());
+
+ // Test validate API key
+ String validateKeyUrl =
String.format("%s/%s/apikeys/validate?key=%s&type=%s",
+ getFullUrl(REST_ENDPOINT), updatedTenant.getItemId(),
newApiKey.getKey(), ApiKey.ApiKeyType.PUBLIC.name());
+ int validateResponse;
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(validateKeyUrl), AuthType.JAAS_ADMIN)) {
+ validateResponse = response.getStatusLine().getStatusCode();
+ }
+ Assert.assertEquals("API key validation should succeed", 200,
validateResponse);
+
+ // Test validate with wrong type
+ String validateWrongTypeUrl =
String.format("%s/%s/apikeys/validate?key=%s&type=%s",
+ getFullUrl(REST_ENDPOINT), updatedTenant.getItemId(),
newApiKey.getKey(), ApiKey.ApiKeyType.PRIVATE.name());
+ int validateWrongTypeResponse;
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(validateWrongTypeUrl), AuthType.JAAS_ADMIN)) {
+ validateWrongTypeResponse =
response.getStatusLine().getStatusCode();
+ }
+ Assert.assertEquals("API key validation with wrong type should
fail", 401, validateWrongTypeResponse);
+
+ // Test delete tenant
+ int deleteResponse;
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpDelete(getFullUrl(REST_ENDPOINT + "/" + updatedTenant.getItemId())),
AuthType.JAAS_ADMIN)) {
+ deleteResponse = response.getStatusLine().getStatusCode();
+ }
+ Assert.assertEquals("Delete response should be 204", 204,
deleteResponse);
+ tenantDeleted = true;
+
+ // Verify tenant is deleted
+ int verifyDeleteResponse;
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(REST_ENDPOINT + "/" + updatedTenant.getItemId())),
AuthType.JAAS_ADMIN)) {
+ verifyDeleteResponse =
response.getStatusLine().getStatusCode();
+ }
+ Assert.assertEquals("Get deleted tenant should return 404", 404,
verifyDeleteResponse);
+ } finally {
+ if (!tenantDeleted) {
+ try (CloseableHttpResponse r = executeHttpRequest(new
HttpDelete(getFullUrl(REST_ENDPOINT + "/" + createdTenant.getItemId())),
AuthType.JAAS_ADMIN)) {
+ // best-effort cleanup
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testTenantEndpointAuthentication() throws Exception {
+ // Test without any authentication
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(REST_ENDPOINT)), AuthType.NONE)) {
+ Assert.assertEquals("Unauthenticated request should be rejected",
401, response.getStatusLine().getStatusCode());
+ }
+
+ // Create test tenant for API key tests
+ BasicCredentialsProvider adminCredsProvider = new
BasicCredentialsProvider();
+ adminCredsProvider.setCredentials(AuthScope.ANY, new
UsernamePasswordCredentials("karaf", "karaf"));
+
+ try (CloseableHttpClient adminClient =
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build())
{
+ Map<String, Object> requestBody = new HashMap<>();
+ requestBody.put("requestedId", "auth-test-tenant");
+ requestBody.put("properties", Collections.emptyMap());
+
+ HttpPost createRequest = new HttpPost(getFullUrl(REST_ENDPOINT));
+ createRequest.setEntity(new
StringEntity(objectMapper.writeValueAsString(requestBody),
ContentType.APPLICATION_JSON));
+
+ String createResponse;
+ Tenant tenant;
+ try (CloseableHttpResponse response =
adminClient.execute(createRequest)) {
+ createResponse = EntityUtils.toString(response.getEntity());
+ tenant = objectMapper.readValue(createResponse, Tenant.class);
+ }
+
+ try {
+ // Test with public API key (should fail)
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(REST_ENDPOINT)), AuthType.PUBLIC_KEY)) {
+ Assert.assertEquals("Public API key should not grant
access to tenant endpoints", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with private API key (should fail)
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl(REST_ENDPOINT)), AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Private API key should not grant
access to tenant endpoints", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with invalid JAAS credentials (should fail)
+ BasicCredentialsProvider wrongCredsProvider = new
BasicCredentialsProvider();
+ wrongCredsProvider.setCredentials(AuthScope.ANY, new
UsernamePasswordCredentials("wrong", "wrong"));
+ try (CloseableHttpClient wrongClient =
HttpClients.custom().setDefaultCredentialsProvider(wrongCredsProvider).build();
+ CloseableHttpResponse response = wrongClient.execute(new
HttpGet(getFullUrl(REST_ENDPOINT)))) {
+ Assert.assertEquals("Invalid JAAS credentials should be
rejected", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with valid JAAS credentials (should succeed)
+ try (CloseableHttpResponse response = adminClient.execute(new
HttpGet(getFullUrl(REST_ENDPOINT)))) {
+ Assert.assertEquals("Valid JAAS credentials should be
accepted", 200, response.getStatusLine().getStatusCode());
+ }
+ } finally {
+ try { tenantService.deleteTenant(tenant.getItemId()); } catch
(Exception ignored) {}
+ }
+ }
+ }
+
+ @Test
+ public void testPublicEndpointAuthentication() throws Exception {
+ // Create test tenant
+ Tenant tenant = tenantService.createTenant("public-test-tenant",
Collections.emptyMap());
+
+ // Refresh persistence to ensure tenant is immediately available for
API key lookup
+ persistenceService.refresh();
+
+ try {
+ // Test without any authentication
+ String sessionId = "test-session-" + System.currentTimeMillis();
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)), AuthType.NONE)) {
+ Assert.assertEquals("Unauthenticated public request should be
rejected", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with private API key (should succeed - private keys have
higher privileges)
+ HttpGet publicRequest = new
HttpGet(getFullUrl("/context.json?sessionId=" + sessionId));
+ publicRequest.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(
+ (tenant.getItemId() + ":" +
tenant.getPrivateApiKey()).getBytes()));
+ try (CloseableHttpResponse response =
executeHttpRequest(publicRequest, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Private API key should grant access to
public endpoints (higher privileges)", 200,
response.getStatusLine().getStatusCode());
+ }
+
+ // Test with valid public API key (should succeed)
+ publicRequest = new HttpGet(getFullUrl("/context.json?sessionId="
+ sessionId));
+ publicRequest.setHeader("X-Unomi-Api-Key",
tenant.getPublicApiKey());
+ try (CloseableHttpResponse response =
executeHttpRequest(publicRequest, AuthType.PUBLIC_KEY)) {
+ Assert.assertEquals("Valid public API key should grant access
to public endpoints", 200, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with JAAS auth (should succeed) — use a fresh request to
avoid carrying X-Unomi-Api-Key from previous step
+ BasicCredentialsProvider adminCredsProvider = new
BasicCredentialsProvider();
+ adminCredsProvider.setCredentials(AuthScope.ANY, new
UsernamePasswordCredentials("karaf", "karaf"));
+ try (CloseableHttpClient adminClient =
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
+ CloseableHttpResponse response = adminClient.execute(new
HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)))) {
+ Assert.assertEquals("JAAS auth should grant access to public
endpoints", 200, response.getStatusLine().getStatusCode());
+ }
+ } finally {
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+ }
+
+ @Test
+ public void testPrivateEndpointAuthentication() throws Exception {
+ // Create test tenant
+ Tenant tenant = tenantService.createTenant("private-test-tenant",
Collections.emptyMap());
+
+ try {
+ // Test without any authentication
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl("/cxs/profiles/count")), AuthType.NONE)) {
+ Assert.assertEquals("Unauthenticated private request should be
rejected", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with public API key (should fail)
+ HttpGet privateRequest = new
HttpGet(getFullUrl("/cxs/profiles/count"));
+ privateRequest.setHeader("X-Unomi-Api-Key",
tenant.getPublicApiKey());
+ try (CloseableHttpResponse response =
executeHttpRequest(privateRequest, AuthType.PUBLIC_KEY)) {
+ Assert.assertEquals("Public API key should not grant access to
private endpoints", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with invalid private API key (should fail)
+ privateRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
+ privateRequest.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(
+ (tenant.getItemId() + ":wrong-key").getBytes()));
+ try (CloseableHttpResponse response =
executeHttpRequest(privateRequest, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Invalid private API key should be
rejected", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with valid private API key (should succeed)
+ privateRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
+ privateRequest.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(
+ (tenant.getItemId() + ":" +
tenant.getPrivateApiKey()).getBytes()));
+ try (CloseableHttpResponse response =
executeHttpRequest(privateRequest, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Valid private API key should grant access
to private endpoints", 200, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with JAAS auth (should succeed) — use a fresh request to
avoid carrying Authorization from previous step
+ BasicCredentialsProvider adminCredsProvider = new
BasicCredentialsProvider();
+ adminCredsProvider.setCredentials(AuthScope.ANY, new
UsernamePasswordCredentials("karaf", "karaf"));
+ try (CloseableHttpClient adminClient =
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
+ CloseableHttpResponse response = adminClient.execute(new
HttpGet(getFullUrl("/cxs/profiles/count")))) {
+ Assert.assertEquals("JAAS auth should grant access to private
endpoints", 200, response.getStatusLine().getStatusCode());
+ }
+ } finally {
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+ }
+
+ @Test
+ public void testTenantIsolation() throws Exception {
+ // Create two tenants
+ Tenant tenant1 = tenantService.createTenant("tenant-1",
Collections.emptyMap());
+ Tenant tenant2 = tenantService.createTenant("tenant-2",
Collections.emptyMap());
+
+ try {
+ // Create profile in tenant1
+ executionContextManager.executeAsTenant(tenant1.getItemId(), () ->
{
+ Profile profile1 = new Profile();
+ profile1.setItemId("profile1");
+ profile1.setProperty("name", "John");
+ persistenceService.save(profile1);
+ });
+
+ // Try to access profile from tenant2
+ executionContextManager.executeAsTenant(tenant2.getItemId(), () ->
{
+ Profile loadedProfile = persistenceService.load("profile1",
Profile.class);
+ Assert.assertNull("Profile should not be accessible from
different tenants", loadedProfile);
+ });
+ } finally {
+ tenantService.deleteTenant(tenant1.getItemId());
+ tenantService.deleteTenant(tenant2.getItemId());
+ }
+ }
+
+ @Test
+ public void testApiKeyAuthentication() throws Exception {
+ // Create test tenant
+ Tenant tenant = tenantService.createTenant("test-tenant-auth",
Collections.emptyMap());
+
+ try {
+ // Test with private API key (should succeed)
+ ApiKey privateKey =
tenantService.generateApiKeyWithType(tenant.getItemId(),
ApiKey.ApiKeyType.PRIVATE, null);
+ HttpGet getRequest = new
HttpGet(getFullUrl("/cxs/profiles/count"));
+ getRequest.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(
+ (tenant.getItemId() + ":" + privateKey.getKey()).getBytes()));
+ try (CloseableHttpResponse response =
executeHttpRequest(getRequest, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Private API key should grant access to
private endpoints", 200, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with JAAS authentication (should succeed)
+ getRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
+ getRequest.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(("karaf:karaf").getBytes()));
+ try (CloseableHttpResponse response =
executeHttpRequest(getRequest, AuthType.JAAS_ADMIN)) {
+ Assert.assertEquals("JAAS authentication should grant access
to private endpoints", 200, response.getStatusLine().getStatusCode());
+ }
+
+ // Test with public API key (should fail)
+ ApiKey publicKey =
tenantService.generateApiKeyWithType(tenant.getItemId(),
ApiKey.ApiKeyType.PUBLIC, null);
+ getRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
+ getRequest.setHeader("X-Unomi-Api-Key", publicKey.getKey());
+ try (CloseableHttpResponse response =
executeHttpRequest(getRequest, AuthType.PUBLIC_KEY)) {
+ Assert.assertEquals("Public API key should not grant access to
private endpoints", 401, response.getStatusLine().getStatusCode());
+ }
+
+ // Test without any authentication (should fail)
+ getRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
+ try (CloseableHttpResponse response =
executeHttpRequest(getRequest, AuthType.NONE)) {
+ Assert.assertEquals("Unauthenticated request should be
rejected", 401, response.getStatusLine().getStatusCode());
+ }
+ } finally {
+ // Cleanup
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+ }
+
+ @Test
+ public void testExpiredApiKey() throws Exception {
+ Tenant tenant = tenantService.createTenant("expired-tenant",
Collections.emptyMap());
+ try {
+ ApiKey apiKey = tenantService.generateApiKey(tenant.getItemId(),
1L); // 1ms validity
+ Thread.sleep(2); // Wait for key to expire
+
Assert.assertFalse(tenantService.validateApiKey(tenant.getItemId(),
apiKey.getKey()));
+ } finally {
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+ }
+
+ @Test
+ public void testTenantDeletion() throws Exception {
+ Tenant tenant = tenantService.createTenant("delete-test",
Collections.emptyMap());
+
+ try {
+ executionContextManager.executeAsTenant(tenant.getItemId(), () -> {
+ Profile profile = new Profile();
+ profile.setItemId("delete-test-profile");
+ persistenceService.save(profile);
+ });
+ } catch (Exception e) {
+ tenantService.deleteTenant(tenant.getItemId());
+ throw e;
+ }
+
+ // Deletion is the operation under test
+ tenantService.deleteTenant(tenant.getItemId());
+
+ Profile loadedProfile = persistenceService.load("delete-test-profile",
Profile.class);
+ Assert.assertNull(loadedProfile);
+ }
+
+ @Test
+ public void testCrossSearchPrevention() throws Exception {
+ // Create two tenants
+ Tenant tenant1 = tenantService.createTenant("search-test-1",
Collections.emptyMap());
+ Tenant tenant2 = tenantService.createTenant("search-test-2",
Collections.emptyMap());
+
+ try {
+ // Add data to tenant1
+ executionContextManager.executeAsTenant(tenant1.getItemId(), () ->
{
+ for (int i = 0; i < 10; i++) {
+ Profile profile = new Profile();
+ profile.setItemId("search-test-" + i);
+ profile.setProperty("testKey", "testValue");
+ persistenceService.save(profile);
+ }
+ });
+
+ // Search from tenant2
+ executionContextManager.executeAsTenant(tenant2.getItemId(), () ->
{
+ Query query = new Query();
+ List<Profile> results = persistenceService.query("testKey",
"testValue", null, Profile.class);
+ Assert.assertEquals(0, results.size());
+ });
+ } finally {
+ tenantService.deleteTenant(tenant1.getItemId());
+ tenantService.deleteTenant(tenant2.getItemId());
+ }
+ }
+
+ @Test
+ public void testPublicPrivateApiKeys() throws Exception {
+ Tenant tenant = tenantService.createTenant("dual-key-tenant",
Collections.emptyMap());
+
+ try {
+ ApiKey publicKey = tenantService.getApiKey(tenant.getItemId(),
ApiKey.ApiKeyType.PUBLIC);
+ ApiKey privateKey = tenantService.getApiKey(tenant.getItemId(),
ApiKey.ApiKeyType.PRIVATE);
+
+ Assert.assertNotNull("Public key should exist", publicKey);
+ Assert.assertNotNull("Private key should exist", privateKey);
+ Assert.assertEquals("Public key should have correct type",
ApiKey.ApiKeyType.PUBLIC, publicKey.getKeyType());
+ Assert.assertEquals("Private key should have correct type",
ApiKey.ApiKeyType.PRIVATE, privateKey.getKeyType());
+
+ Assert.assertTrue("Public key should validate as public",
+ tenantService.validateApiKeyWithType(tenant.getItemId(),
publicKey.getKey(), ApiKey.ApiKeyType.PUBLIC));
+ Assert.assertFalse("Public key should not validate as private",
+ tenantService.validateApiKeyWithType(tenant.getItemId(),
publicKey.getKey(), ApiKey.ApiKeyType.PRIVATE));
+ Assert.assertTrue("Private key should validate as private",
+ tenantService.validateApiKeyWithType(tenant.getItemId(),
privateKey.getKey(), ApiKey.ApiKeyType.PRIVATE));
+ Assert.assertFalse("Private key should not validate as public",
+ tenantService.validateApiKeyWithType(tenant.getItemId(),
privateKey.getKey(), ApiKey.ApiKeyType.PUBLIC));
+ } finally {
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+ }
+
+ @Test
+ public void testTenantLookupByApiKey() throws Exception {
+ Tenant tenant = tenantService.createTenant("lookup-tenant",
Collections.emptyMap());
+
+ try {
+ ApiKey publicKey = tenantService.getApiKey(tenant.getItemId(),
ApiKey.ApiKeyType.PUBLIC);
+ ApiKey privateKey = tenantService.getApiKey(tenant.getItemId(),
ApiKey.ApiKeyType.PRIVATE);
+
+ persistenceService.refresh();
+
+ Tenant foundByPublic =
tenantService.getTenantByApiKey(publicKey.getKey());
+ Tenant foundByPrivate =
tenantService.getTenantByApiKey(privateKey.getKey());
+
+ Assert.assertEquals("Should find correct tenant by public key",
tenant.getItemId(), foundByPublic.getItemId());
+ Assert.assertEquals("Should find correct tenant by private key",
tenant.getItemId(), foundByPrivate.getItemId());
+
+ Tenant foundByPublicAsPublic =
tenantService.getTenantByApiKey(publicKey.getKey(), ApiKey.ApiKeyType.PUBLIC);
+ Tenant foundByPublicAsPrivate =
tenantService.getTenantByApiKey(publicKey.getKey(), ApiKey.ApiKeyType.PRIVATE);
+ Tenant foundByPrivateAsPrivate =
tenantService.getTenantByApiKey(privateKey.getKey(), ApiKey.ApiKeyType.PRIVATE);
+ Tenant foundByPrivateAsPublic =
tenantService.getTenantByApiKey(privateKey.getKey(), ApiKey.ApiKeyType.PUBLIC);
+
+ Assert.assertNotNull("Should find tenant by public key when type
matches", foundByPublicAsPublic);
+ Assert.assertNull("Should not find tenant by public key when type
is private", foundByPublicAsPrivate);
+ Assert.assertNotNull("Should find tenant by private key when type
matches", foundByPrivateAsPrivate);
+ Assert.assertNull("Should not find tenant by private key when type
is public", foundByPrivateAsPublic);
+ } finally {
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+ }
+
+ @Test
+ public void testTenantIdValidation() throws Exception {
+ // Test tenant ID too long (>32 chars)
+ try {
+
tenantService.createTenant("this-tenant-id-is-way-too-long-to-be-valid",
Collections.emptyMap());
+ Assert.fail("Should reject tenant ID longer than 32 characters");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test tenant ID with invalid characters
+ try {
+ tenantService.createTenant("invalid@chars#here",
Collections.emptyMap());
+ Assert.fail("Should reject tenant ID with invalid characters");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test tenant ID starting with hyphen
+ try {
+ tenantService.createTenant("-invalid-start",
Collections.emptyMap());
+ Assert.fail("Should reject tenant ID starting with hyphen");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test tenant ID ending with hyphen
+ try {
+ tenantService.createTenant("invalid-end-", Collections.emptyMap());
+ Assert.fail("Should reject tenant ID ending with hyphen");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test tenant ID starting with underscore
+ try {
+ tenantService.createTenant("_invalid_start",
Collections.emptyMap());
+ Assert.fail("Should reject tenant ID starting with underscore");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test tenant ID ending with underscore
+ try {
+ tenantService.createTenant("invalid_end_", Collections.emptyMap());
+ Assert.fail("Should reject tenant ID ending with underscore");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test system tenant ID
+ try {
+ tenantService.createTenant("SYSTEM", Collections.emptyMap());
+ Assert.fail("Should reject SYSTEM tenant ID");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ }
+
+ // Test duplicate tenant ID
+ Tenant tenant = tenantService.createTenant("valid-tenant",
Collections.emptyMap());
+ try {
+ tenantService.createTenant("valid-tenant", Collections.emptyMap());
+ Assert.fail("Should reject duplicate tenant ID");
+ } catch (IllegalArgumentException e) {
+ // Expected
+ } finally {
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+
+ // Test valid tenant ID with hyphens
+ tenant = tenantService.createTenant("valid-tenant-123",
Collections.emptyMap());
+ Assert.assertNotNull("Should create tenant with valid ID containing
hyphens", tenant);
+ Assert.assertEquals("Tenant ID should match requested ID",
"valid-tenant-123", tenant.getItemId());
+ tenantService.deleteTenant(tenant.getItemId());
+
+ // Test valid tenant ID with underscores
+ tenant = tenantService.createTenant("valid_tenant_123",
Collections.emptyMap());
+ Assert.assertNotNull("Should create tenant with valid ID containing
underscores", tenant);
+ Assert.assertEquals("Tenant ID should match requested ID",
"valid_tenant_123", tenant.getItemId());
+ tenantService.deleteTenant(tenant.getItemId());
+
+ // Test valid tenant ID with mix of hyphens and underscores
+ tenant = tenantService.createTenant("valid-tenant_123",
Collections.emptyMap());
+ Assert.assertNotNull("Should create tenant with valid ID containing
both hyphens and underscores", tenant);
+ Assert.assertEquals("Tenant ID should match requested ID",
"valid-tenant_123", tenant.getItemId());
+ tenantService.deleteTenant(tenant.getItemId());
+ }
+
+ @Test
+ public void testContextJsonAuthenticationDetection() throws Exception {
+ // Test that context.json is properly detected as a public endpoint
+ // This test verifies that the AUTO authentication works correctly
+ String sessionId = "test-session-" + System.currentTimeMillis();
+ try (CloseableHttpResponse response = executeHttpRequest(new
HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)), AuthType.AUTO)) {
+ // Should succeed with public key authentication
+ Assert.assertEquals("context.json should be accessible with
auto-detected public authentication",
+ 200, response.getStatusLine().getStatusCode());
+ }
+ }
+}
diff --git
a/itests/src/test/resources/persona/persona-with-sessions-payload.json
b/itests/src/test/resources/persona/persona-with-sessions-payload.json
new file mode 100644
index 000000000..15944f46f
--- /dev/null
+++ b/itests/src/test/resources/persona/persona-with-sessions-payload.json
@@ -0,0 +1,36 @@
+{
+ "persona": {
+ "itemId": "test-persona-with-sessions",
+ "version": null,
+ "properties": {
+ "firstName": "Test",
+ "lastName": "Persona",
+ "email": "[email protected]"
+ },
+ "systemProperties": {},
+ "segments": [],
+ "scores": {},
+ "mergedWith": null,
+ "consents": {}
+ },
+ "sessions": [
+ {
+ "itemId": "test-session-1",
+ "scope": "test",
+ "profile": {
+ "itemId": "test-persona-with-sessions",
+ "properties": {
+ "firstName": "Test",
+ "lastName": "Persona"
+ }
+ },
+ "properties": {
+ "operatingSystemName": "OS X",
+ "sessionStartDate": "2024-01-01T00:00:00Z"
+ },
+ "timeStamp": "2024-01-01T00:00:00Z",
+ "lastEventDate": "2024-01-01T01:00:00Z"
+ }
+ ]
+}
+
diff --git
a/rest/src/main/java/org/apache/unomi/rest/models/EventCollectorResponse.java
b/rest/src/main/java/org/apache/unomi/rest/models/EventCollectorResponse.java
index d1ef9dd3d..bca06297b 100644
---
a/rest/src/main/java/org/apache/unomi/rest/models/EventCollectorResponse.java
+++
b/rest/src/main/java/org/apache/unomi/rest/models/EventCollectorResponse.java
@@ -44,11 +44,6 @@ public class EventCollectorResponse implements Serializable {
public EventCollectorResponse() {
}
- /**
- * Creates a new EventCollectorResponse with the specified update flags.
- *
- * @param updated The bitwise combination of EventService flags indicating
what was updated
- */
public EventCollectorResponse(int updated) {
this.updated = updated;
}