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" + } + ] +} +
