This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-139-878-integration-tests
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 7b0165f298311d5a3935f46679de60e0aced8f38
Author: Serge Huber <[email protected]>
AuthorDate: Tue May 19 15:49:20 2026 +0200

    UNOMI-139 UNOMI-878: Platform integration tests
    
    Add TenantIT, SecurityIT, PersonaIT, EventsCollectorIT, SchedulerIT and
    persona test fixture. Register platform ITs in AllITs (including
    TenantIT, SchedulerIT, EventsCollectorIT for CI coverage per split plan).
---
 .../test/java/org/apache/unomi/itests/AllITs.java  |   5 +
 .../org/apache/unomi/itests/EventsCollectorIT.java | 139 +++++
 .../java/org/apache/unomi/itests/PersonaIT.java    | 154 ++++++
 .../java/org/apache/unomi/itests/SchedulerIT.java  | 167 ++++++
 .../java/org/apache/unomi/itests/SecurityIT.java   | 105 ++++
 .../java/org/apache/unomi/itests/TenantIT.java     | 592 +++++++++++++++++++++
 .../persona/persona-with-sessions-payload.json     |  36 ++
 7 files changed, 1198 insertions(+)

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..c2f54ce8e 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,
@@ -75,6 +77,9 @@ import org.junit.runners.Suite.SuiteClasses;
         OtherCommandsIT.class,
         HealthCheckIT.class,
         LegacyQueryBuilderMappingIT.class,
+        TenantIT.class,
+        SchedulerIT.class,
+        EventsCollectorIT.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..a4cdc3c8c
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
@@ -0,0 +1,139 @@
+/*
+ * 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.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+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 javax.inject.Inject;
+import java.util.Collections;
+import java.util.Date;
+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 final static String TEST_TENANT_ID = "test-tenant";
+    private final static String TEST_TENANT_NAME = "Test Tenant";
+    private final static String TEST_TENANT_DESCRIPTION = "Test tenant for 
events collector";
+    private final static String CONTENT_TYPE_HEADER = "Content-Type";
+    private final static String APPLICATION_JSON = "application/json";
+
+    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
+        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
+        request.removeHeaders("X-Unomi-Api-Key"); // We need to do this since 
we are reusing the request object since the last call added auth to it.
+        HttpClientThatWaitsForUnomi.setTestTenant(null, null, null);
+        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..4e65072d2
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/PersonaIT.java
@@ -0,0 +1,154 @@
+/*
+ * 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 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)) {
+                // Endpoint exists if we get 200 (persona exists), 204 (no 
content - persona not found), or 404 (not found)
+                int statusCode = response.getStatusLine().getStatusCode();
+                return (statusCode == 200 || statusCode == 204 || statusCode 
== 404) ? response : null;
+            } catch (Exception e) {
+                return null;
+            }
+        }, Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+    }
+
+    @After
+    public void tearDown() {
+        // Clean up: delete the test persona
+        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 // Give more time for indexing
+        );
+
+        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..e7289be69
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/SchedulerIT.java
@@ -0,0 +1,167 @@
+/*
+ * 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.util.EntityUtils;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.services.SchedulerService;
+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 org.ops4j.pax.exam.util.Filter;
+
+import javax.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+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() {
+        // Clean up test task
+        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 {
+        CloseableHttpResponse response = delete("/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 {
+        // First make the task fail
+        TestTaskExecutor.shouldFail.set(true);
+        try {
+            Thread.sleep(1500); // Wait for task to execute and fail
+        } catch (InterruptedException e) {
+            // Ignore
+        }
+
+        // Now retry the task
+        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 task = objectMapper.readValue(responseBody, 
ScheduledTask.class);
+        assertNotNull("Task should not be null", task);
+        assertEquals("Task should be scheduled", 
ScheduledTask.TaskStatus.SCHEDULED, task.getStatus());
+        assertEquals("Failure count should be reset", 0, 
task.getFailureCount());
+    }
+
+    private static class TestTaskExecutor implements TaskExecutor {
+        static final AtomicBoolean shouldFail = new AtomicBoolean(false);
+
+        @Override
+        public String getTaskType() {
+            return TEST_TASK_TYPE;
+        }
+
+        @Override
+        public void execute(ScheduledTask task, TaskStatusCallback callback) 
throws Exception {
+            if (shouldFail.get()) {
+                throw new Exception("Test failure");
+            }
+            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..5e6b51847
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/SecurityIT.java
@@ -0,0 +1,105 @@
+/*
+ * 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.ObjectMapper;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.unomi.api.ContextRequest;
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.services.PersonalizationService;
+import org.apache.unomi.persistence.spi.CustomObjectMapper;
+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 javax.security.auth.Subject;
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class SecurityIT extends BaseIT {
+
+    private static final String SESSION_ID = "vuln-session-id";
+
+    private ObjectMapper objectMapper;
+
+    @Before
+    public void setUp() {
+        objectMapper = CustomObjectMapper.getObjectMapper();
+    }
+
+    @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());
+        }
+    }
+
+    private TestUtils.RequestResponse executeContextJSONRequest(HttpPost 
request, String sessionId) throws IOException {
+        return TestUtils.executeContextJSONRequest(request, sessionId);
+    }
+
+}
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..999dca531
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
@@ -0,0 +1,592 @@
+/*
+ * 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());
+
+        // 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);
+
+        // 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);
+    }
+
+    @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);
+            }
+
+            // 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());
+            }
+
+            // Cleanup
+            try (CloseableHttpResponse response = adminClient.execute(new 
HttpDelete(getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId())))) {
+                // Response closed automatically
+            }
+        }
+    }
+
+    @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)
+            BasicCredentialsProvider adminCredsProvider = new 
BasicCredentialsProvider();
+            adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+            try (CloseableHttpClient adminClient = 
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
+                 CloseableHttpResponse response = 
adminClient.execute(publicRequest)) {
+                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)
+            BasicCredentialsProvider adminCredsProvider = new 
BasicCredentialsProvider();
+            adminCredsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+            try (CloseableHttpClient adminClient = 
HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
+                 CloseableHttpResponse response = 
adminClient.execute(privateRequest)) {
+                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());
+
+        // Generate API keys
+        ApiKey apiKey1 = tenantService.generateApiKey(tenant1.getItemId(), 
null);
+        ApiKey apiKey2 = tenantService.generateApiKey(tenant2.getItemId(), 
null);
+
+        // 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);
+        });
+    }
+
+    @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 {
+        // Create tenants with short-lived API key
+        Tenant tenant = tenantService.createTenant("expired-tenant", 
Collections.emptyMap());
+        ApiKey apiKey = tenantService.generateApiKey(tenant.getItemId(), 1L); 
// 1ms validity
+
+        Thread.sleep(2); // Wait for key to expire
+
+        Assert.assertFalse(tenantService.validateApiKey(tenant.getItemId(), 
apiKey.getItemId()));
+    }
+
+    @Test
+    public void testTenantDeletion() throws Exception {
+        // Create tenants
+        Tenant tenant = tenantService.createTenant("delete-test", 
Collections.emptyMap());
+
+        // Create data for tenants
+        executionContextManager.executeAsTenant(tenant.getItemId(), () -> {
+            Profile profile = new Profile();
+            profile.setItemId("delete-test-profile");
+            persistenceService.save(profile);
+        });
+
+        // Delete tenants
+        tenantService.deleteTenant(tenant.getItemId());
+
+        // Verify data is inaccessible
+        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());
+
+        // 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());
+        });
+    }
+
+    @Test
+    public void testPublicPrivateApiKeys() throws Exception {
+        // Create tenant
+        Tenant tenant = tenantService.createTenant("dual-key-tenant", 
Collections.emptyMap());
+
+        // Verify both keys were created during tenant creation
+        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());
+
+        // Test key type validation
+        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));
+    }
+
+    @Test
+    public void testTenantLookupByApiKey() throws Exception {
+        // Create tenant
+        Tenant tenant = tenantService.createTenant("lookup-tenant", 
Collections.emptyMap());
+        ApiKey publicKey = tenantService.getApiKey(tenant.getItemId(), 
ApiKey.ApiKeyType.PUBLIC);
+        ApiKey privateKey = tenantService.getApiKey(tenant.getItemId(), 
ApiKey.ApiKeyType.PRIVATE);
+
+        persistenceService.refresh();
+
+        // Test lookup by key
+        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());
+
+        // Test lookup with type validation
+        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);
+    }
+
+    @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"
+    }
+  ]
+}
+

Reply via email to