This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 36e9ac4cf [UNOMI-962] Harden 3.1 REST API: V2 compat auth,
missing-resource 404s, and export validation (#823)
36e9ac4cf is described below
commit 36e9ac4cf49514ecadc81e07c1518e88c3505bc2
Author: Serge Huber <[email protected]>
AuthorDate: Tue Jul 14 06:34:13 2026 +0200
[UNOMI-962] Harden 3.1 REST API: V2 compat auth, missing-resource 404s, and
export validation (#823)
REST API Hardening
---
.cursor/rules/git-town-stack.mdc | 33 +++++
.cursor/rules/jira-tickets.mdc | 68 +++++++++
.cursor/rules/pr-descriptions.mdc | 95 +++++++++++++
extensions/router/router-service/pom.xml | 15 ++
.../router/services/ProfileExportServiceImpl.java | 12 ++
.../services/ProfileExportServiceImplTest.java | 105 ++++++++++++++
.../apache/unomi/itests/V2CompatibilityModeIT.java | 10 +-
.../ElasticSearchPersistenceServiceImpl.java | 2 +-
persistence-opensearch/core/pom.xml | 15 ++
.../OpenSearchPersistenceServiceImpl.java | 2 +-
.../core/PropertyConditionOSQueryBuilder.java | 2 +
.../core/PropertyConditionOSQueryBuilderTest.java | 68 +++++++++
.../rest/authentication/AuthenticationFilter.java | 25 +++-
.../rest/endpoints/DefinitionsServiceEndPoint.java | 3 +
.../unomi/rest/endpoints/EventServiceEndpoint.java | 9 +-
.../unomi/rest/endpoints/GoalsServiceEndPoint.java | 6 +
.../endpoints/MissingResourceEndpointsTest.java | 158 +++++++++++++++++++++
.../services/impl/goals/GoalsServiceImpl.java | 3 +
.../services/impl/profiles/ProfileServiceImpl.java | 6 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 1 +
.../services/impl/goals/GoalsServiceImplTest.java | 7 +
.../impl/profiles/ProfileServiceImplTest.java | 29 +++-
22 files changed, 660 insertions(+), 14 deletions(-)
diff --git a/.cursor/rules/git-town-stack.mdc b/.cursor/rules/git-town-stack.mdc
new file mode 100644
index 000000000..162ff6481
--- /dev/null
+++ b/.cursor/rules/git-town-stack.mdc
@@ -0,0 +1,33 @@
+---
+description: Git Town stacked PR workflow — use external git-town-stack for
merges and tree ops
+alwaysApply: true
+---
+
+# Git Town stack (mandatory)
+
+This repo uses Git Town stacked PRs. Tool lives **outside** this repo (do not
add scripts/docs here):
+
+```bash
+GTS="${GIT_TOWN_STACK:-${GIT_TOWN_STACK_HOME:-$HOME/projects/sergehuber/git-town-stack}/git-town-stack.sh}"
+cd "$(git rev-parse --show-toplevel)"
+bash "$GTS" doctor
+bash "$GTS" status
+```
+
+**Doctor** also flags unfinished Git Town runstate, mid-rebase, unmerged
index, index lock.
+
+For merge, rebase, sync, branch delete, or PR base changes on stack branches:
run **doctor** and **status** first.
+
+**After each squash-merge on GitHub:** `bash "$GTS" after-merge <pr#>
--dry-run` then `after-merge <pr#>`.
+
+**Routine sync (parent not merged):** `bash "$GTS" sync-stack`
+
+**Never:** delete a remote branch while a child PR uses it as base; blind `git
town sync --stack` after parent squash-merge; commit `git-town-stack.sh` into
Unomi PRs.
+
+**Dirty tree:** `git stash push -u` before `git town continue` / sync.
+
+**Stale Git Town runstate** (blocks switch/sync): `git town status reset` if
stack already fixed; never `git town undo` after good force-push.
+
+**Squash-merge conflicts:** `git rebase --onto master <merged-parent-tip>
<child>` then `git push --force-with-lease`. Tool docs:
`~/projects/sergehuber/git-town-stack/docs/KNOWN-ISSUES.md`
+
+**Current stack (verify with status):** master → UNOMI-880 (#763) → UNOMI-139
(#766). Merge top-down.
diff --git a/.cursor/rules/jira-tickets.mdc b/.cursor/rules/jira-tickets.mdc
new file mode 100644
index 000000000..4eb3fa7fc
--- /dev/null
+++ b/.cursor/rules/jira-tickets.mdc
@@ -0,0 +1,68 @@
+---
+description: JIRA ticket style — problem-focused descriptions, plain-language
summary, Confluence wiki markup
+alwaysApply: true
+---
+
+# JIRA tickets (Apache Unomi / UNOMI-*)
+
+When the user asks you to **draft, revise, or review a JIRA issue** (title,
summary, description, acceptance criteria in JIRA), follow this style.
+
+## Summary (plain language)
+
+- One or two sentences a **non-technical reader** can understand (PM, release
manager, new contributor).
+- State the **user or team pain**, not the fix.
+- Avoid file names, script names, class names, flags, and stack jargon unless
the user explicitly wants a technical summary.
+
+## Description (problem only)
+
+- Describe **what is wrong today**, **who is affected**, and **why it
matters**.
+- Use a *What “good” would look like* section for **needs and outcomes** —
still no implementation.
+- Do **not** include in the main description: proposed scripts, architecture,
file paths, CLI commands, code snippets, or step-by-step build instructions.
Put those in a separate sub-task, PR, or “Technical notes” only if the user
asks.
+
+## Structure (Confluence wiki markup)
+
+Write the description body in **JIRA / Confluence wiki markup** (not
Markdown), so it can be pasted into JIRA’s wiki editor.
+
+| Element | Markup |
+|---------|--------|
+| Heading 2 | `h2. Section title` |
+| Heading 3 | `h3. Subsection` |
+| Bold | `*text*` |
+| Italic | `_text_` |
+| Bullet list | `* item` (one `*` per line) |
+| Numbered list | `# item` |
+| Horizontal rule | `----` |
+| Inline code | `{{monospace}}` |
+| Code block | `{code}...{code}` |
+| Info panel | `{panel:title=Note|borderStyle=solid}...{panel}` |
+| Link | `[label|https://example.com]` |
+
+## Template
+
+```
+h2. Summary
+
+Plain-language summary here (1–2 sentences).
+
+h2. What is going wrong today
+
+* Bullet describing the current pain
+* Another concrete symptom
+
+h2. Impact
+
+* Who suffers and how (developers, CI, releases, …)
+
+h2. Who is affected
+
+* Role or team
+
+h2. What good would look like
+
+* Observable outcome we need — *not* how to build it
+```
+
+## Title
+
+- Short, problem-oriented (e.g. “Integration test runs lack clear memory and
resource visibility”).
+- Prefer “lack / hard to / cannot / unreliable” over solution verbs (“Add”,
“Implement”, “Introduce”) unless the user wants a delivery-style title.
diff --git a/.cursor/rules/pr-descriptions.mdc
b/.cursor/rules/pr-descriptions.mdc
new file mode 100644
index 000000000..d73c47688
--- /dev/null
+++ b/.cursor/rules/pr-descriptions.mdc
@@ -0,0 +1,95 @@
+---
+description: GitHub PR descriptions — plain-language summary, self-contained
context, concise structure
+alwaysApply: true
+---
+
+# GitHub pull requests (Apache Unomi)
+
+When the user asks you to **draft, revise, or create a PR description**,
follow this style. Write for reviewers who may use **English as a second
language** — see **Simple English** below.
+
+## Simple English (required)
+
+Apply to the **whole** description, not only the plain-language summary.
+
+- Use **short sentences** (one idea each). Prefer **common words** over formal
or idiomatic ones.
+- Prefer: _fix_, _stop_, _allow_, _check_, _save_, _run_, _before_, _after_.
Avoid: _leverage_, _remediate_, _no-op_, _land in that window_, _close those
race windows_ (say _fix the race_ or _prevent the bug_ instead).
+- Use **active voice**: “This PR adds a lock” not “A lock is introduced by
this PR”.
+- Avoid idioms, slang, and culture-specific phrases (_at the wrong moment_,
_say the word_, _stand on its own_ → _can be read without other tickets_).
+- Avoid double negatives and nested clauses. Split long sentences into two.
+- Technical terms are fine when needed (scheduler, cache, lock), but explain
the **effect** in plain words nearby.
+- Bullets: start with a **verb**; keep each bullet to one or two short lines.
+
+## Plain-language summary (required)
+
+- Open with **2–3 sentences** a **non-technical reader** can understand (PM,
release manager, new contributor).
+- Explain **what Unomi feature or behavior** is affected in everyday terms
(background jobs, rules/segments cache, REST API, etc.).
+- State the **user or operator pain** (duplicate work, wrong data, failed
shutdown, flaky CI) — not the fix.
+- Avoid file names, class names, JIRA keys, and stack jargon in this section
unless the user explicitly wants a technical opener.
+
+## Self-contained (required)
+
+- The description must **stand alone**: a reviewer who has not read linked
tickets should understand **what changed and why**.
+- Do **not** rely on “see UNOMI-xxx” or “as discussed in …” as the main
explanation.
+- JIRA keys and issue links belong in a **References** line at the bottom, not
in the summary.
+
+## Technical summary (required, concise)
+
+- One short section with **3–5 bullets** on the actual fix — enough for a
developer to orient without reading every commit.
+- Name subsystems (scheduler, cache, REST) not individual methods unless a
method name is the whole point.
+- Skip commit hashes and per-commit review maps unless the PR is unusually
large or the user asks for one.
+- Keep the same **Simple English** rules: short lines, clear verbs, no idioms.
+
+## Test plan (required)
+
+- Checklist of what was run or what CI will run.
+- Keep it factual; do not repeat the summary.
+
+## Stacked PRs (when applicable)
+
+This repo uses Git Town stacked PRs (see `.cursor/rules/git-town-stack.mdc`).
When the PR is **not** based directly on `master`:
+
+- Add a **Stack** section near the top (after the plain-language summary).
+- Link the **parent PR** (the PR whose branch is the base branch) with title
and number, e.g. `[UNOMI-880 (#763)](https://github.com/apache/unomi/pull/763)`.
+- List any **known child PRs** at write time (same link format). If none exist
yet, say so explicitly: _No child PRs yet._
+- One sentence on **where this PR sits** in the merge order (e.g. “Merge after
#763; #766 depends on this branch.”).
+- The plain-language summary and **What changed** must still stand alone —
stack links are navigation, not a substitute for explaining this PR’s scope.
+
+Verify stack relationships with `bash "$GTS" status` before writing; do not
guess parent/child numbers.
+
+Skip the Stack section when the PR targets `master` directly and has no stack
children.
+
+## Structure (Markdown for GitHub)
+
+```markdown
+## Plain-language summary
+
+…
+
+## Stack
+
+- **Parent:** [title (#nnn)](https://github.com/apache/unomi/pull/nnn) — merge
this PR first
+- **Children:** [title (#mmm)](https://github.com/apache/unomi/pull/mmm) — or
_None yet_
+- **Order:** …
+
+## What changed
+
+* …
+
+## Test plan
+
+- [ ] …
+
+## References
+
+UNOMI-xxx, UNOMI-yyy
+```
+
+## Title
+
+- Short and outcome-oriented; JIRA keys in the title are fine when the repo
convention uses them.
+- Prefer describing the fix domain over internal ticket-only wording.
+
+## Conciseness
+
+- Target **~15–25 lines** total for typical bug-fix PRs (add ~3–5 lines when a
Stack section is needed).
+- Omit anti-patterns: duplicate bullets, “Review map” tables keyed only by
ticket, empty boilerplate sections.
diff --git a/extensions/router/router-service/pom.xml
b/extensions/router/router-service/pom.xml
index 15d9629b4..19393d9bb 100644
--- a/extensions/router/router-service/pom.xml
+++ b/extensions/router/router-service/pom.xml
@@ -82,6 +82,21 @@
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ProfileExportServiceImpl.java
b/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ProfileExportServiceImpl.java
index 7a1fc34ec..aea02cf38 100644
---
a/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ProfileExportServiceImpl.java
+++
b/extensions/router/router-service/src/main/java/org/apache/unomi/router/services/ProfileExportServiceImpl.java
@@ -59,6 +59,15 @@ public class ProfileExportServiceImpl implements
ProfileExportService {
}
public String extractProfilesBySegment(ExportConfiguration
exportConfiguration) {
+ Object segmentProperty = exportConfiguration.getProperty("segment");
+ if (segmentProperty == null ||
StringUtils.isBlank(segmentProperty.toString())) {
+ throw new IllegalArgumentException("Export segment is required");
+ }
+ Map<String, String> mapping = (Map<String, String>)
exportConfiguration.getProperty("mapping");
+ if (mapping == null || mapping.isEmpty()) {
+ throw new IllegalArgumentException("Export mapping is required");
+ }
+
Collection<PropertyType> propertiesDef =
persistenceService.query("target", "profiles", null, PropertyType.class);
Condition segmentCondition = new Condition();
@@ -97,6 +106,9 @@ public class ProfileExportServiceImpl implements
ProfileExportService {
public String convertProfileToCSVLine(Profile profile, ExportConfiguration
exportConfiguration, Collection<PropertyType> propertiesDef) {
Map<String, String> mapping = (Map<String, String>)
exportConfiguration.getProperty("mapping");
+ if (mapping == null || mapping.isEmpty()) {
+ throw new IllegalArgumentException("Export mapping is required");
+ }
String lineToWrite = "";
for (int i = 0; i < mapping.size(); i++) {
String propertyName = mapping.get(String.valueOf(i));
diff --git
a/extensions/router/router-service/src/test/java/org/apache/unomi/router/services/ProfileExportServiceImplTest.java
b/extensions/router/router-service/src/test/java/org/apache/unomi/router/services/ProfileExportServiceImplTest.java
new file mode 100644
index 000000000..d3efb6016
--- /dev/null
+++
b/extensions/router/router-service/src/test/java/org/apache/unomi/router/services/ProfileExportServiceImplTest.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.router.services;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.PropertyType;
+import org.apache.unomi.router.api.ExportConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ProfileExportServiceImplTest {
+
+ private ProfileExportServiceImpl profileExportService;
+
+ @BeforeEach
+ void setUp() {
+ profileExportService = new ProfileExportServiceImpl();
+ }
+
+ @Test
+ void
extractProfilesBySegment_missingSegment_throwsIllegalArgumentException() {
+ ExportConfiguration configuration = new ExportConfiguration();
+ configuration.setProperty("mapping", Map.of("0", "firstName"));
+
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () ->
profileExportService.extractProfilesBySegment(configuration));
+
+ assertEquals("Export segment is required", exception.getMessage());
+ }
+
+ @Test
+ void
extractProfilesBySegment_blankSegment_throwsIllegalArgumentException() {
+ ExportConfiguration configuration = new ExportConfiguration();
+ configuration.setProperty("segment", " ");
+ configuration.setProperty("mapping", Map.of("0", "firstName"));
+
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () ->
profileExportService.extractProfilesBySegment(configuration));
+
+ assertEquals("Export segment is required", exception.getMessage());
+ }
+
+ @Test
+ void
extractProfilesBySegment_missingMapping_throwsIllegalArgumentException() {
+ ExportConfiguration configuration = new ExportConfiguration();
+ configuration.setProperty("segment", "frequent-buyers");
+
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () ->
profileExportService.extractProfilesBySegment(configuration));
+
+ assertEquals("Export mapping is required", exception.getMessage());
+ }
+
+ @Test
+ void
convertProfileToCSVLine_missingMapping_throwsIllegalArgumentException() {
+ ExportConfiguration configuration = new ExportConfiguration();
+ Profile profile = new Profile("profile-1");
+
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () -> profileExportService.convertProfileToCSVLine(profile,
configuration, Collections.emptyList()));
+
+ assertEquals("Export mapping is required", exception.getMessage());
+ }
+
+ @Test
+ void convertProfileToCSVLine_nullPropertyValue_writesEmptyField() {
+ ExportConfiguration configuration = new ExportConfiguration();
+ configuration.setColumnSeparator(",");
+ Map<String, String> mapping = new HashMap<>();
+ mapping.put("0", "firstName");
+ configuration.setProperty("mapping", mapping);
+
+ Profile profile = new Profile("profile-1");
+ PropertyType propertyType = new PropertyType();
+ propertyType.setMetadata(new Metadata("firstName"));
+
+ String line = profileExportService.convertProfileToCSVLine(profile,
configuration,
+ Collections.singletonList(propertyType));
+
+ assertTrue(line.isEmpty());
+ }
+}
diff --git
a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
index e8318fb5b..cce6c5484 100644
--- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -293,9 +293,13 @@ public class V2CompatibilityModeIT extends BaseIT {
try (CloseableHttpClient adminClient = HttpClients.custom()
.setDefaultCredentialsProvider(credsProvider)
.setDefaultRequestConfig(requestConfig)
- .build();
- CloseableHttpResponse jaasResponse =
adminClient.execute(getRequest)) {
- assertEquals("Private endpoint with JAAS auth should work in V2
compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode());
+ .build()) {
+ try (CloseableHttpResponse jaasResponse =
adminClient.execute(getRequest)) {
+ assertEquals("Private endpoint with JAAS auth should work in
V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode());
+ }
+ try (CloseableHttpResponse privacyResponse =
adminClient.execute(new HttpGet(getFullUrl("/cxs/privacy/info")))) {
+ assertEquals("GET /cxs/privacy/info with Karaf auth should
work in V2 compatibility mode", 200,
privacyResponse.getStatusLine().getStatusCode());
+ }
}
}
diff --git
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index d900c6975..2756e78ad 100644
---
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -1754,7 +1754,7 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
Map<String, Object> subSubMappings = (Map<String, Object>)
subMappings.computeIfAbsent("properties", k -> new HashMap<>());
if (subSubMappings.containsKey(property.getItemId())) {
- LOGGER.warn("Mapping already exists for type {} and property
{}", itemType, property.getItemId());
+ LOGGER.debug("Mapping already exists for type {} and property
{}", itemType, property.getItemId());
return;
}
diff --git a/persistence-opensearch/core/pom.xml
b/persistence-opensearch/core/pom.xml
index 90adbc735..023565513 100644
--- a/persistence-opensearch/core/pom.xml
+++ b/persistence-opensearch/core/pom.xml
@@ -136,6 +136,21 @@
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
diff --git
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index 0888dbc36..7451ee5a5 100644
---
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -1711,7 +1711,7 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
Map<String, Object> subSubMappings = (Map<String, Object>)
subMappings.computeIfAbsent("properties", k -> new HashMap<>());
if (subSubMappings.containsKey(property.getItemId())) {
- LOGGER.warn("Mapping already exists for type " + itemType + "
and property " + property.getItemId());
+ LOGGER.debug("Mapping already exists for type " + itemType + "
and property " + property.getItemId());
return;
}
diff --git
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilder.java
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilder.java
index de7547b88..22787c1b5 100644
---
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilder.java
+++
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilder.java
@@ -241,6 +241,8 @@ public class PropertyConditionOSQueryBuilder implements
ConditionOSQueryBuilder
return fieldValueBuilder.stringValue(convertDateToISO((Date)
fieldValue).toString());
} else if (fieldValue instanceof OffsetDateTime) {
return
fieldValueBuilder.stringValue(convertDateToISO((OffsetDateTime)
fieldValue).toString());
+ } else if (fieldValue == null) {
+ throw new IllegalArgumentException("Impossible to build OS filter,
unsupported value type: null");
} else {
throw new IllegalArgumentException("Impossible to build OS filter,
unsupported value type: " + fieldValue.getClass().getName());
}
diff --git
a/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilderTest.java
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilderTest.java
new file mode 100644
index 000000000..fe4a0f72f
--- /dev/null
+++
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/querybuilders/core/PropertyConditionOSQueryBuilderTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.persistence.opensearch.querybuilders.core;
+
+import org.apache.unomi.api.conditions.Condition;
+import
org.apache.unomi.persistence.opensearch.ConditionOSQueryBuilderDispatcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ExtendWith(MockitoExtension.class)
+class PropertyConditionOSQueryBuilderTest {
+
+ @Mock
+ private ConditionOSQueryBuilderDispatcher dispatcher;
+
+ @Test
+ void buildQuery_inOperatorWithNullValue_throwsIllegalArgumentException() {
+ Condition condition = new Condition();
+ condition.setParameter("comparisonOperator", "in");
+ condition.setParameter("propertyName", "properties.firstName");
+ condition.setParameter("propertyValues", Arrays.asList("Jane", null));
+
+ PropertyConditionOSQueryBuilder builder = new
PropertyConditionOSQueryBuilder();
+
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () -> builder.buildQuery(condition, Collections.emptyMap(),
dispatcher));
+
+ assertTrue(exception.getMessage().contains("null"));
+ }
+
+ @Test
+ void buildQuery_equalsWithMissingValue_throwsIllegalArgumentException() {
+ Condition condition = new Condition();
+ condition.setParameter("comparisonOperator", "equals");
+ condition.setParameter("propertyName", "properties.firstName");
+
+ PropertyConditionOSQueryBuilder builder = new
PropertyConditionOSQueryBuilder();
+
+ IllegalArgumentException exception =
assertThrows(IllegalArgumentException.class,
+ () -> builder.buildQuery(condition, Collections.emptyMap(),
dispatcher));
+
+ assertEquals("Impossible to build OS filter, missing value for
condition using comparisonOperator: equals, and propertyName:
properties.firstName",
+ exception.getMessage());
+ }
+}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index b4db2754e..3c59c1cf6 100644
---
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -25,7 +25,6 @@ import org.apache.karaf.jaas.boot.principal.RolePrincipal;
import org.apache.karaf.jaas.boot.principal.UserPrincipal;
import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.security.SecurityService;
-import org.apache.unomi.api.security.TenantPrincipal;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.tenants.ApiKey;
@@ -47,7 +46,9 @@ import org.apache.commons.lang3.StringUtils;
import java.io.IOException;
import java.util.Base64;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
/**
* A filter that combines JAAS authentication with tenant API key
authentication:
@@ -310,14 +311,28 @@ public class AuthenticationFilter implements
ContainerRequestFilter {
}
Subject jaasSubject = ((RolePrefixSecurityContextImpl)
securityContext).getSubject();
- // Build a merged subject that combines the JAAS principals
with a TenantPrincipal
- // for the default tenant, so that resolveTenantId() can find
it downstream.
+ // Private endpoints in V2 compatibility mode require system
administrator
+ // authentication (like V2) — a JAAS login alone isn't enough,
since any Karaf
+ // user (not just admins) can authenticate against the realm.
+ if
(!securityService.extractRolesFromSubject(jaasSubject).contains(UnomiRoles.ADMINISTRATOR))
{
+ logger.debug("V2 compatibility mode: authenticated user
lacks administrator role, denying access to private endpoint");
+ unauthorized(requestContext);
+ return;
+ }
+
+ // Build a merged subject that combines the JAAS principals
with tenant admin
+ // principals for the default tenant, so that
resolveTenantId() can find it downstream.
String defaultTenantId =
restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
Subject mergedSubject = new Subject();
mergedSubject.getPrincipals().addAll(jaasSubject.getPrincipals());
if (StringUtils.isNotBlank(defaultTenantId)) {
- mergedSubject.getPrincipals().add(new
TenantPrincipal(defaultTenantId));
-
executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
+
mergedSubject.getPrincipals().addAll(securityService.createSubject(defaultTenantId,
true).getPrincipals());
+ Set<String> roles =
securityService.extractRolesFromSubject(mergedSubject);
+ Set<String> permissions = new HashSet<>();
+ for (String role : roles) {
+
permissions.addAll(securityService.getPermissionsForRole(role));
+ }
+ executionContextManager.setCurrentContext(new
ExecutionContext(defaultTenantId, roles, permissions));
} else {
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/endpoints/DefinitionsServiceEndPoint.java
b/rest/src/main/java/org/apache/unomi/rest/endpoints/DefinitionsServiceEndPoint.java
index 97b9a1697..2a2c3c62d 100644
---
a/rest/src/main/java/org/apache/unomi/rest/endpoints/DefinitionsServiceEndPoint.java
+++
b/rest/src/main/java/org/apache/unomi/rest/endpoints/DefinitionsServiceEndPoint.java
@@ -281,6 +281,9 @@ public class DefinitionsServiceEndPoint {
@Path("/values/{valueTypeId}")
public RESTValueType getValueType(@PathParam("valueTypeId") String id,
@HeaderParam("Accept-Language") String language) {
ValueType valueType = definitionsService.getValueType(id);
+ if (valueType == null) {
+ throw new NotFoundException("Value type not found: " + id);
+ }
return localizationHelper.generateValueType(valueType, language);
}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
index c36ff131a..e3497922d 100644
---
a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
+++
b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
@@ -26,6 +26,7 @@ import org.osgi.service.component.annotations.Reference;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
import java.util.Set;
/**
@@ -72,8 +73,12 @@ public class EventServiceEndpoint {
*/
@GET
@Path("/{id}")
- public Event getEvents(@PathParam("id") final String id) {
- return eventService.getEvent(id);
+ public Response getEvents(@PathParam("id") final String id) {
+ Event event = eventService.getEvent(id);
+ if (event == null) {
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+ return Response.ok(event).build();
}
/**
diff --git
a/rest/src/main/java/org/apache/unomi/rest/endpoints/GoalsServiceEndPoint.java
b/rest/src/main/java/org/apache/unomi/rest/endpoints/GoalsServiceEndPoint.java
index 3c750fd69..2236ba876 100644
---
a/rest/src/main/java/org/apache/unomi/rest/endpoints/GoalsServiceEndPoint.java
+++
b/rest/src/main/java/org/apache/unomi/rest/endpoints/GoalsServiceEndPoint.java
@@ -123,6 +123,9 @@ public class GoalsServiceEndPoint {
@GET
@Path("/{goalID}/report")
public GoalReport getGoalReport(@PathParam("goalID") String goalId) {
+ if (goalsService.getGoal(goalId) == null) {
+ throw new NotFoundException("Goal not found: " + goalId);
+ }
return goalsService.getGoalReport(goalId);
}
@@ -136,6 +139,9 @@ public class GoalsServiceEndPoint {
@POST
@Path("/{goalID}/report")
public GoalReport getGoalReport(@PathParam("goalID") String goalId,
AggregateQuery query) {
+ if (goalsService.getGoal(goalId) == null) {
+ throw new NotFoundException("Goal not found: " + goalId);
+ }
return goalsService.getGoalReport(goalId, query);
}
}
diff --git
a/rest/src/test/java/org/apache/unomi/rest/endpoints/MissingResourceEndpointsTest.java
b/rest/src/test/java/org/apache/unomi/rest/endpoints/MissingResourceEndpointsTest.java
new file mode 100644
index 000000000..e3199b6be
--- /dev/null
+++
b/rest/src/test/java/org/apache/unomi/rest/endpoints/MissingResourceEndpointsTest.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.endpoints;
+
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.ValueType;
+import org.apache.unomi.api.goals.Goal;
+import org.apache.unomi.api.goals.GoalReport;
+import org.apache.unomi.api.query.AggregateQuery;
+import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.GoalsService;
+import org.apache.unomi.rest.models.RESTValueType;
+import org.apache.unomi.rest.service.impl.LocalizationHelper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.core.Response;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MissingResourceEndpointsTest {
+
+ @Mock
+ private DefinitionsService definitionsService;
+
+ @Mock
+ private LocalizationHelper localizationHelper;
+
+ @Mock
+ private GoalsService goalsService;
+
+ @Mock
+ private EventService eventService;
+
+ @Test
+ void getValueType_missingValueType_throwsNotFound() {
+ when(definitionsService.getValueType("missing-type")).thenReturn(null);
+
+ DefinitionsServiceEndPoint endpoint = new DefinitionsServiceEndPoint();
+ endpoint.setDefinitionsService(definitionsService);
+ endpoint.setLocalizationHelper(localizationHelper);
+
+ NotFoundException exception = assertThrows(NotFoundException.class,
+ () -> endpoint.getValueType("missing-type", "en"));
+
+ assertTrue(exception.getMessage().contains("missing-type"));
+ verify(localizationHelper,
never()).generateValueType(any(ValueType.class), anyString());
+ }
+
+ @Test
+ void getValueType_existingValueType_returnsLocalizedValueType() {
+ ValueType valueType = new ValueType();
+ valueType.setId("string");
+ RESTValueType restValueType = new RESTValueType();
+ restValueType.setId("string");
+
+ when(definitionsService.getValueType("string")).thenReturn(valueType);
+ when(localizationHelper.generateValueType(valueType,
"en")).thenReturn(restValueType);
+
+ DefinitionsServiceEndPoint endpoint = new DefinitionsServiceEndPoint();
+ endpoint.setDefinitionsService(definitionsService);
+ endpoint.setLocalizationHelper(localizationHelper);
+
+ RESTValueType result = endpoint.getValueType("string", "en");
+
+ assertEquals("string", result.getId());
+ }
+
+ @Test
+ void getGoalReport_missingGoal_throwsNotFound() {
+ when(goalsService.getGoal("missing-goal")).thenReturn(null);
+
+ GoalsServiceEndPoint endpoint = new GoalsServiceEndPoint();
+ endpoint.setGoalsService(goalsService);
+
+ assertThrows(NotFoundException.class, () ->
endpoint.getGoalReport("missing-goal"));
+ }
+
+ @Test
+ void getGoalReportPost_missingGoal_throwsNotFound() {
+ when(goalsService.getGoal("missing-goal")).thenReturn(null);
+
+ GoalsServiceEndPoint endpoint = new GoalsServiceEndPoint();
+ endpoint.setGoalsService(goalsService);
+
+ assertThrows(NotFoundException.class,
+ () -> endpoint.getGoalReport("missing-goal", new
AggregateQuery()));
+ }
+
+ @Test
+ void getGoalReport_existingGoal_delegatesToService() {
+ Goal goal = new Goal();
+ goal.setItemId("goal-1");
+ GoalReport report = new GoalReport();
+
+ when(goalsService.getGoal("goal-1")).thenReturn(goal);
+ when(goalsService.getGoalReport("goal-1")).thenReturn(report);
+
+ GoalsServiceEndPoint endpoint = new GoalsServiceEndPoint();
+ endpoint.setGoalsService(goalsService);
+
+ assertEquals(report, endpoint.getGoalReport("goal-1"));
+ }
+
+ @Test
+ void getEvent_missingEvent_returnsNotFound() {
+ when(eventService.getEvent("missing-event")).thenReturn(null);
+
+ EventServiceEndpoint endpoint = new EventServiceEndpoint();
+ endpoint.setEventService(eventService);
+
+ Response response = endpoint.getEvents("missing-event");
+
+ assertEquals(404, response.getStatus());
+ }
+
+ @Test
+ void getEvent_existingEvent_returnsOkWithBody() {
+ Event event = new Event();
+ event.setItemId("event-1");
+
+ when(eventService.getEvent("event-1")).thenReturn(event);
+
+ EventServiceEndpoint endpoint = new EventServiceEndpoint();
+ endpoint.setEventService(eventService);
+
+ Response response = endpoint.getEvents("event-1");
+
+ assertEquals(200, response.getStatus());
+ assertEquals(event, response.getEntity());
+ }
+}
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java
b/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java
index af4724686..18f7b2637 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java
@@ -518,6 +518,9 @@ public class GoalsServiceImpl extends
AbstractMultiTypeCachingService implements
condition.setParameter("subConditions", list);
Goal g = getGoal(goalId);
+ if (g == null) {
+ return null;
+ }
Condition goalTargetCondition = new
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
goalTargetCondition.setParameter("propertyName",
"systemProperties.goals." + goalId+ "TargetReached");
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
index 352849cb8..f24636ca9 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
@@ -541,7 +541,11 @@ public class ProfileServiceImpl extends
AbstractMultiTypeCachingService implemen
List<String> segmentNames = new ArrayList<String>();
for (String segment : profile.getSegments()) {
Segment s = segmentService.getSegmentDefinition(segment);
- segmentNames.add(csvEncode(s.getMetadata().getName()));
+ if (s == null || s.getMetadata() == null) {
+ segmentNames.add(csvEncode(segment));
+ } else {
+ segmentNames.add(csvEncode(s.getMetadata().getName()));
+ }
}
sb.append(csvEncode(StringUtils.join(segmentNames, ",")));
sb.append('\n');
diff --git a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index b83b0daf8..6002b721a 100644
--- a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -378,6 +378,7 @@
<property name="persistenceService" ref="persistenceService"/>
<property name="definitionsService" ref="definitionsServiceImpl"/>
<property name="bundleContext" ref="blueprintBundleContext"/>
+ <property name="contextManager" ref="executionContextManager"/>
</bean>
<bean id="profileServiceImpl"
class="org.apache.unomi.services.impl.profiles.ProfileServiceImpl"
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/goals/GoalsServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/goals/GoalsServiceImplTest.java
index 2884b4a60..c19e63973 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/goals/GoalsServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/goals/GoalsServiceImplTest.java
@@ -54,6 +54,7 @@ import java.io.IOException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -293,4 +294,10 @@ public class GoalsServiceImplTest {
return null;
});
}
+ @Test
+ public void testGetGoalReportMissingGoalReturnsNull() {
+ assertNull(goalsService.getGoalReport("missing-goal-id"));
+ assertNull(goalsService.getGoalReport("missing-goal-id", null));
+ }
+
}
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java
index 1585e69c6..2df2cd516 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java
@@ -17,6 +17,9 @@
package org.apache.unomi.services.impl.profiles;
import org.apache.unomi.api.*;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.query.Query;
+import org.apache.unomi.api.services.SegmentService;
import org.apache.unomi.api.services.SchedulerService;
import org.apache.unomi.persistence.spi.PersistenceService;
import
org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher;
@@ -61,6 +64,7 @@ public class ProfileServiceImplTest {
private KarafSecurityService securityService;
private AuditServiceImpl auditService;
private SchedulerService schedulerService;
+ private SegmentService segmentService;
private static final String TENANT_1 = "tenant1";
private static final String SYSTEM_TENANT = "system";
@@ -117,6 +121,7 @@ public class ProfileServiceImplTest {
.thenReturn(Collections.enumeration(Arrays.asList(personasUrl)));
// Set up profile service
+ segmentService = mock(SegmentService.class);
profileService = new ProfileServiceImpl();
profileService.setBundleContext(bundleContext);
profileService.setPersistenceService(persistenceService);
@@ -126,7 +131,7 @@ public class ProfileServiceImplTest {
profileService.setCacheService(multiTypeCacheService);
// Ensure tenantService is available for initial data loading
profileService.setTenantService(tenantService);
-
+ profileService.setSegmentService(segmentService);
profileService.postConstruct();
@@ -671,4 +676,26 @@ public class ProfileServiceImplTest {
}
}
+ @Test
+ public void
testExportProfilesPropertiesToCsv_missingSegmentDefinition_usesSegmentId() {
+ executionContextManager.executeAsTenant(TENANT_1, () -> {
+ Profile profile = new Profile("profile-export-missing-segment");
+ profile.setProperty("firstName", "Jane");
+ profile.getSegments().add("missing-segment-id");
+ profileService.setForceRefreshOnSave(true);
+ profileService.save(profile);
+
+
when(segmentService.getSegmentDefinition("missing-segment-id")).thenReturn(null);
+
+ Query query = new Query();
+
+ String csv = profileService.exportProfilesPropertiesToCsv(query);
+
+ assertNotNull(csv, "CSV export should succeed when segment
definition is missing");
+ assertTrue(csv.contains("missing-segment-id"),
+ "CSV should contain the raw segment id when segment metadata
is unavailable");
+ return null;
+ });
+ }
+
}