From c79bbbf6ea757e9404ab3c8638ecf307d99fe981 Mon Sep 17 00:00:00 2001 From: Francois Prunayre Date: Wed, 13 May 2026 16:46:02 +0200 Subject: [PATCH 1/3] Elasticsearch / Proxy / More robust filter injection. --- .../org/fao/geonet/api/es/EsHTTPProxy.java | 105 ++++++++---- .../fao/geonet/api/es/EsHTTPProxyTest.java | 158 +++++++++++++++++- 2 files changed, 225 insertions(+), 38 deletions(-) diff --git a/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java b/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java index fc87edac9074..80c588ab541e 100644 --- a/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java +++ b/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java @@ -31,6 +31,7 @@ import com.fasterxml.jackson.databind.MappingIterator; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.collect.Sets; import com.jayway.jsonpath.DocumentContext; @@ -432,14 +433,13 @@ private void call(ServiceContext context, HttpSession httpSession, HttpServletRe if (indexNode != null) { ((ObjectNode) node).put("index", defaultIndex); } else { - final JsonNode queryNode = node.get("query"); - if (queryNode != null) { - addFilterToQuery(context, objectMapper, node); - if (selectionBucket != null) { - // Multisearch are not supposed to work with a bucket. - // Only one request is store in session - session.setProperty(Geonet.Session.SEARCH_REQUEST + selectionBucket, node); - } + JsonNode queryNode = node.get("query"); + + addFilterToQuery(context, objectMapper, node); + if (selectionBucket != null) { + // Multisearch are not supposed to work with a bucket. + // Only one request is store in session + session.setProperty(Geonet.Session.SEARCH_REQUEST + selectionBucket, node); } final JsonNode sourceNode = node.get("_source"); if (sourceNode != null) { @@ -474,7 +474,6 @@ private void addRequiredField(ArrayNode source) { source.add(Geonet.IndexFieldNames.OWNER); source.add(Geonet.IndexFieldNames.ID); } - private void addFilterToQuery(ServiceContext context, ObjectMapper objectMapper, JsonNode esQuery) throws Exception { @@ -489,46 +488,82 @@ private void addFilterToQuery(ServiceContext context, JsonNode queryNode = esQuery.get("query"); -// if (queryNode == null) { -// // Add default filter if no query provided -// ObjectNode objectNodeQuery = objectMapper.createObjectNode(); -// objectNodeQuery.set("filter", nodeFilter); -// ((ObjectNode) esQuery).set("query", objectNodeQuery); -// } else - if (queryNode.get("function_score") != null) { - // Add filter node to the bool element of the query if provided - ObjectNode objectNode = (ObjectNode) queryNode.get("function_score").get("query").get("bool"); - insertFilter(objectNode, nodeFilter); - } else if (queryNode.get("bool") != null) { - // Add filter node to the bool element of the query if provided - ObjectNode objectNode = (ObjectNode) queryNode.get("bool"); - insertFilter(objectNode, nodeFilter); - } else { - // If no bool node in the query, create the bool node and add the query and filter nodes to it - ObjectNode copy = esQuery.get("query").deepCopy(); + // Defensive: if no "query", create a bool { must: match_all, filter: nodeFilter } + if (queryNode == null || queryNode.isNull()) { + ObjectNode boolNode = objectMapper.createObjectNode(); + // prefer must = match_all object (same shape as existing code) + ObjectNode matchAll = objectMapper.createObjectNode(); + matchAll.putObject("match_all"); + boolNode.set("must", matchAll); + boolNode.set("filter", nodeFilter); + ((ObjectNode) esQuery).set("query", objectMapper.createObjectNode().set("bool", boolNode)); + return; + } + + // Try to find the boolean node where to insert the filter. + // Prefer function_score.query.bool if present because function_score + // needs the filter to be applied to the inner query used for scoring. + JsonNode functionScoreNode = queryNode.get("function_score"); + if (functionScoreNode != null && functionScoreNode.isObject()) { + JsonNode innerQuery = functionScoreNode.get("query"); + if (innerQuery == null || innerQuery.isNull()) { + // create function_score.query.bool with only the filter + ObjectNode boolNode = objectMapper.createObjectNode(); + boolNode.set("filter", nodeFilter); + ((ObjectNode) functionScoreNode).set("query", objectMapper.createObjectNode().set("bool", boolNode)); + return; + } + + JsonNode innerBool = innerQuery.get("bool"); + if (innerBool != null && innerBool.isObject()) { + insertFilter((ObjectNode) innerBool, nodeFilter); + return; + } - ObjectNode objectNodeBool = objectMapper.createObjectNode(); - objectNodeBool.set("must", copy); - objectNodeBool.set("filter", nodeFilter); + // innerQuery exists but isn't a bool -> wrap it into a bool { must: , filter: } + ObjectNode newBool = objectMapper.createObjectNode(); + newBool.set("must", innerQuery.deepCopy()); + newBool.set("filter", nodeFilter); + ((ObjectNode) functionScoreNode).set("query", objectMapper.createObjectNode().set("bool", newBool)); + return; + } - ((ObjectNode) queryNode).removeAll(); - ((ObjectNode) queryNode).set("bool", objectNodeBool); + // Top-level bool + JsonNode boolNode = queryNode.get("bool"); + if (boolNode != null && boolNode.isObject()) { + insertFilter((ObjectNode) boolNode, nodeFilter); + return; } + + // Other query shapes: wrap existing query into a bool { must: , filter: nodeFilter } + ObjectNode copy = queryNode.deepCopy(); + ObjectNode objectNodeBool = objectMapper.createObjectNode(); + objectNodeBool.set("must", copy); + objectNodeBool.set("filter", nodeFilter); + + // Replace the existing "query" content with the new bool + ((ObjectNode) queryNode).removeAll(); + ((ObjectNode) queryNode).set("bool", objectNodeBool); } private void insertFilter(ObjectNode objectNode, JsonNode nodeFilter) { JsonNode filter = objectNode.get("filter"); - if (filter != null && filter.isArray()) { + if (filter == null || filter.isNull()) { + objectNode.set("filter", nodeFilter); + } else if (filter.isArray()) { ((ArrayNode) filter).add(nodeFilter); } else { - objectNode.set("filter", nodeFilter); + // existing filter is an object (or other non-array) -> convert to array preserving both + ArrayNode arr = JsonNodeFactory.instance.arrayNode(); + arr.add(filter); + arr.add(nodeFilter); + objectNode.set("filter", arr); } } - /** * Add search privilege criteria to a query. */ - private String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) throws Exception { + protected String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) throws Exception { return String.format(filterTemplate, EsFilterBuilder.build(context, type, isSearchingForDraft, node)); diff --git a/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java b/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java index d3f4662db06a..1e2ef9921887 100644 --- a/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java +++ b/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java @@ -23,7 +23,10 @@ package org.fao.geonet.api.es; + +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import jeeves.server.UserSession; import jeeves.server.context.ServiceContext; @@ -34,6 +37,7 @@ import org.fao.geonet.kernel.schema.MetadataSchema; import org.fao.geonet.kernel.schema.MetadataSchemaOperationFilter; import org.fao.geonet.repository.UserGroupRepository; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.InjectMocks; @@ -49,9 +53,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; public class EsHTTPProxyTest { @@ -129,4 +131,154 @@ public void testProcessMetadataSchemaFiltersGroupOwner() throws Exception { assertFalse("someField should be filtered when user is not in groupOwner", doc.get("_source").has("someField")); } + + private static final String QUERY_FILTER = "{\"query_string\":{\"query\":\"(op0:(1)) AND (draft:n OR draft:e)\"}}"; + + private static class TestableEsHTTPProxy extends EsHTTPProxy { + @Override + protected String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) { + return QUERY_FILTER; + } + } + + private final ObjectMapper mapper = new ObjectMapper(); + + private Method getAddFilterToQueryMethod() throws Exception { + Method m = EsHTTPProxy.class.getDeclaredMethod("addFilterToQuery", ServiceContext.class, com.fasterxml.jackson.databind.ObjectMapper.class, com.fasterxml.jackson.databind.JsonNode.class); + m.setAccessible(true); + return m; + } + + private JsonNode buildExpectedFilterNode() throws Exception { + return mapper.readTree(QUERY_FILTER); + } + + private void invokeAddFilter(EsHTTPProxy proxy, ObjectNode root) throws Exception { + getAddFilterToQueryMethod().invoke(proxy, null, mapper, root); + } + + private void assertAppendedFilter(ArrayNode filters, JsonNode expectedFilter) { + Assert.assertEquals("Expected original filter + injected access filter", 2, filters.size()); + Assert.assertEquals("Injected filter should be appended as second filter", expectedFilter, filters.get(1)); + } + + @Test + public void shouldCreateBoolQueryWithMatchAllAndAccessFilterWhenQueryIsMissing() throws Exception { + EsHTTPProxy proxy = new TestableEsHTTPProxy(); + JsonNode expectedFilter = buildExpectedFilterNode(); + + ObjectNode root = mapper.createObjectNode(); + + invokeAddFilter(proxy, root); + + JsonNode query = root.get("query"); + Assert.assertNotNull("A query node should be created", query); + JsonNode bool = query.get("bool"); + Assert.assertNotNull(bool); + Assert.assertTrue("The bool query should contain a must clause", bool.has("must")); + Assert.assertTrue("The bool query should contain a filter clause", bool.has("filter")); + + JsonNode must = bool.get("must"); + Assert.assertTrue("Missing query should be replaced by match_all", must.has("match_all")); + Assert.assertEquals("Expected access filter was not injected", expectedFilter, bool.get("filter")); + } + + @Test + public void shouldConvertSingleBoolFilterToArrayAndAppendAccessFilter() throws Exception { + EsHTTPProxy proxy = new TestableEsHTTPProxy(); + JsonNode expectedFilter = buildExpectedFilterNode(); + + ObjectNode filterObj = mapper.createObjectNode(); + filterObj.putObject("term").put("a", "b"); + + ObjectNode boolNode = mapper.createObjectNode(); + boolNode.set("must", mapper.createObjectNode().putObject("match").put("field", "value")); + boolNode.set("filter", filterObj); + + ObjectNode root = mapper.createObjectNode(); + root.set("query", mapper.createObjectNode().set("bool", boolNode)); + + invokeAddFilter(proxy, root); + + JsonNode resultingFilter = root.get("query").get("bool").get("filter"); + Assert.assertTrue("Existing object filter should be converted to array", resultingFilter.isArray()); + ArrayNode arr = (ArrayNode) resultingFilter; + Assert.assertEquals("Original filter should stay first", filterObj, arr.get(0)); + assertAppendedFilter(arr, expectedFilter); + } + + @Test + public void shouldAppendAccessFilterToExistingBoolFilterArray() throws Exception { + EsHTTPProxy proxy = new TestableEsHTTPProxy(); + JsonNode expectedFilter = buildExpectedFilterNode(); + + ObjectNode existingFilter = mapper.createObjectNode(); + existingFilter.putObject("term").put("c", "d"); + + ArrayNode filterArray = mapper.createArrayNode(); + filterArray.add(existingFilter); + + ObjectNode boolNode = mapper.createObjectNode(); + boolNode.set("filter", filterArray); + + ObjectNode root = mapper.createObjectNode(); + root.set("query", mapper.createObjectNode().set("bool", boolNode)); + + invokeAddFilter(proxy, root); + + JsonNode resultingFilter = root.get("query").get("bool").get("filter"); + Assert.assertTrue("Filter should remain an array", resultingFilter.isArray()); + ArrayNode arr = (ArrayNode) resultingFilter; + Assert.assertEquals(existingFilter, arr.get(0)); + assertAppendedFilter(arr, expectedFilter); + } + + @Test + public void shouldWrapFunctionScoreInnerQueryIntoBoolAndInjectAccessFilter() throws Exception { + EsHTTPProxy proxy = new TestableEsHTTPProxy(); + JsonNode expectedFilter = buildExpectedFilterNode(); + + ObjectNode innerQuery = mapper.createObjectNode(); + innerQuery.putObject("match").put("title", "abc"); + + ObjectNode functionScore = mapper.createObjectNode(); + functionScore.set("query", innerQuery); + functionScore.putArray("functions"); // keep valid shape + + ObjectNode root = mapper.createObjectNode(); + root.set("query", mapper.createObjectNode().set("function_score", functionScore)); + + invokeAddFilter(proxy, root); + + JsonNode newFunctionQuery = root.get("query").get("function_score").get("query"); + Assert.assertTrue("function_score.query should become a bool query", newFunctionQuery.has("bool")); + JsonNode bool = newFunctionQuery.get("bool"); + Assert.assertTrue(bool.has("must")); + Assert.assertTrue(bool.has("filter")); + Assert.assertEquals(innerQuery, bool.get("must")); + Assert.assertEquals(expectedFilter, bool.get("filter")); + } + + @Test + public void shouldAppendAccessFilterWhenFunctionScoreInnerBoolAlreadyHasFilter() throws Exception { + EsHTTPProxy proxy = new TestableEsHTTPProxy(); + JsonNode expectedFilter = buildExpectedFilterNode(); + + ObjectNode innerBool = mapper.createObjectNode(); + innerBool.set("must", mapper.createObjectNode().putObject("match").put("f", "v")); + innerBool.set("filter", mapper.createObjectNode().putObject("term").put("x", "y")); + + ObjectNode functionScore = mapper.createObjectNode(); + functionScore.set("query", mapper.createObjectNode().set("bool", innerBool)); + + ObjectNode root = mapper.createObjectNode(); + root.set("query", mapper.createObjectNode().set("function_score", functionScore)); + + invokeAddFilter(proxy, root); + + JsonNode resultingFilter = root.get("query").get("function_score").get("query").get("bool").get("filter"); + Assert.assertTrue("Inner bool filter should be converted to array", resultingFilter.isArray()); + ArrayNode arr = (ArrayNode) resultingFilter; + assertAppendedFilter(arr, expectedFilter); + } } From a4b67b259e4cc6aafc036e2caf2b85016698e54f Mon Sep 17 00:00:00 2001 From: josegar74 Date: Thu, 14 May 2026 15:42:40 +0200 Subject: [PATCH 2/3] EsHTTPProxy refactor --- .../org/fao/geonet/api/es/EsHTTPProxy.java | 611 +----------------- .../es/EsResponseContentTypeValidator.java | 80 +++ .../fao/geonet/api/es/EsSearchEndpoints.java | 40 ++ .../fao/geonet/api/es/ObjectNodeUtils.java | 59 ++ .../query/EsQueryFilterBuilder.java | 214 ++++++ .../es/processors/query/EsQueryProcessor.java | 91 +++ .../EsDocumentMetadataFiltersProcessor.java | 194 ++++++ .../response/EsDocumentProcessor.java | 43 ++ .../EsDocumentRelatedTypesProcessor.java | 79 +++ .../EsDocumentRemovePrivilegesProcessor.java | 53 ++ .../EsDocumentSelectionInfoProcessor.java | 58 ++ .../response/EsDocumentUserInfoProcessor.java | 148 +++++ .../response/EsResponseProcessor.java | 143 ++++ .../fao/geonet/api/records/MetadataUtils.java | 6 +- .../fao/geonet/api/es/EsHTTPProxyTest.java | 455 +++++++------ .../EsResponseContentTypeValidatorTest.java | 119 ++++ .../geonet/api/es/ObjectNodeUtilsTest.java | 102 +++ .../query/EsQueryFilterBuilderTest.java | 266 ++++++++ .../query/EsQueryProcessorTest.java | 168 +++++ ...sDocumentMetadataFiltersProcessorTest.java | 258 ++++++++ .../EsDocumentRelatedTypesProcessorTest.java | 159 +++++ ...DocumentRemovePrivilegesProcessorTest.java | 100 +++ .../EsDocumentSelectionInfoProcessorTest.java | 115 ++++ .../EsDocumentUserInfoProcessorTest.java | 186 ++++++ .../response/EsResponseProcessorTest.java | 190 ++++++ 25 files changed, 3127 insertions(+), 810 deletions(-) create mode 100644 services/src/main/java/org/fao/geonet/api/es/EsResponseContentTypeValidator.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/EsSearchEndpoints.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/ObjectNodeUtils.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilder.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessor.java create mode 100644 services/src/main/java/org/fao/geonet/api/es/processors/response/EsResponseProcessor.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/EsResponseContentTypeValidatorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/ObjectNodeUtilsTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilderTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryProcessorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessorTest.java create mode 100644 services/src/test/java/org/fao/geonet/api/es/processors/response/EsResponseProcessorTest.java diff --git a/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java b/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java index 80c588ab541e..02289663c9fb 100644 --- a/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java +++ b/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2001-2025 Food and Agriculture Organization of the + * Copyright (C) 2001-2026 Food and Agriculture Organization of the * United Nations (FAO-UN), United Nations World Food Programme (WFP) * and United Nations Environment Programme (UNEP) * @@ -23,20 +23,6 @@ package org.fao.geonet.api.es; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.MappingIterator; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.collect.Sets; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import com.jayway.jsonpath.PathNotFoundException; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -45,29 +31,17 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import jeeves.server.UserSession; import jeeves.server.context.ServiceContext; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.fao.geonet.Constants; -import org.fao.geonet.NodeInfo; import org.fao.geonet.api.ApiUtils; -import org.fao.geonet.api.records.MetadataUtils; -import org.fao.geonet.api.records.model.related.AssociatedRecord; +import org.fao.geonet.api.es.processors.response.EsResponseProcessor; +import org.fao.geonet.api.es.processors.query.EsQueryProcessor; import org.fao.geonet.api.records.model.related.RelatedItemType; -import org.fao.geonet.constants.Edit; import org.fao.geonet.constants.Geonet; -import org.fao.geonet.domain.*; import org.fao.geonet.index.es.EsRestClient; -import org.fao.geonet.kernel.AccessManager; -import org.fao.geonet.kernel.SchemaManager; import org.fao.geonet.kernel.SelectionManager; -import org.fao.geonet.kernel.datamanager.IMetadataUtils; -import org.fao.geonet.kernel.schema.MetadataOperationFilterType; -import org.fao.geonet.kernel.schema.MetadataSchema; -import org.fao.geonet.kernel.schema.MetadataSchemaOperationFilter; -import org.fao.geonet.kernel.search.EsFilterBuilder; -import org.fao.geonet.repository.SourceRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -86,8 +60,8 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.sql.SQLException; import java.util.*; import java.util.zip.DeflaterInputStream; import java.util.zip.DeflaterOutputStream; @@ -95,43 +69,19 @@ import java.util.zip.GZIPOutputStream; +/** + * Proxy from GeoNetwork {@code /{portal}}/api} to Elasticsearch service. + * The portal and privileges are included the search provided by the user. + */ @RequestMapping(value = { "/{portal}/api" }) @Tag(name = "search", description = "Proxy for Elasticsearch catalog search operations") @Controller -/** - * Proxy from GeoNetwork {@code /{portal}}/api} to Elasticsearch service. - * - * The portal and privileges are included the search provided by the user. - */ public class EsHTTPProxy { - public static final String[] _validContentTypes = { - "application/json", "text/plain" - }; - private static final Logger LOGGER = LoggerFactory.getLogger(Geonet.INDEX_ENGINE); - /** - * Privileges filter only allows - * * op0 (ie. view operation) contains one of the ids of your groups - */ - private static final String filterTemplate = " {\n" + - " \t\"query_string\": {\n" + - " \t\t\"query\": \"%s\"\n" + - " \t}\n" + - "}"; - - private static final String SEARCH_ENDPOINT = "_search"; - private static final String MULTISEARCH_ENDPOINT = "_msearch"; - - @Autowired - AccessManager accessManager; - - @Autowired - NodeInfo node; - @Autowired - SourceRepository sourceRepository; + private static final Logger LOGGER = LoggerFactory.getLogger(Geonet.INDEX_ENGINE); @Value("${es.index.records:gn-records}") private String defaultIndex; @@ -148,148 +98,23 @@ public class EsHTTPProxy { /** * Ignore list of headers handled by proxy implementation directly. */ - private String[] proxyHeadersIgnoreList = {"Content-Length"}; + private final String[] proxyHeadersIgnoreList = {"Content-Length"}; @Autowired private EsRestClient client; @Autowired - private SchemaManager schemaManager; + private EsResponseProcessor responseProcessor; - public EsHTTPProxy() { - } - - private static Integer getInteger(ObjectNode node, String name) { - final JsonNode sub = node.get(name); - return sub != null ? sub.asInt() : null; - } - - private static String getString(ObjectNode node, String name) { - final JsonNode sub = node.get(name); - return sub != null ? sub.asText() : null; - } - - private static String getSourceString(ObjectNode node, String name) { - final JsonNode sub = node.get("_source").get(name); - return sub != null ? sub.asText() : null; - } - - private static Integer getSourceInteger(ObjectNode node, String name) { - final JsonNode sub = node.get("_source").get(name); - return sub != null ? sub.asInt() : null; - } - - private static void addSelectionInfo(ObjectNode doc, Set selections) { - final String uuid = getSourceString(doc, Geonet.IndexFieldNames.UUID); - doc.put(Edit.Info.Elem.SELECTED, selections.contains(uuid)); - } - - private static void addRelatedTypes(ObjectNode doc, - RelatedItemType[] relatedTypes, - ServiceContext context) { - Map> related = null; - try { - related = MetadataUtils.getAssociated( - context, - context.getBean(IMetadataUtils.class) - .findOne(doc.get("_source").get("id").asText()), - relatedTypes, 0, 1000); - } catch (Exception e) { - LOGGER.warn("Failed to load related types for {}. Error is: {}", - getSourceString(doc, Geonet.IndexFieldNames.UUID), - e.getMessage() - ); - } - doc.putPOJO("related", related); - } - - public static void addUserInfo(ObjectNode doc, ServiceContext context) throws Exception { - final Integer owner = getSourceInteger(doc, Geonet.IndexFieldNames.OWNER); - final Integer groupOwner = getSourceInteger(doc, Geonet.IndexFieldNames.GROUP_OWNER); - final String id = getSourceString(doc, Geonet.IndexFieldNames.ID); - - ObjectMapper objectMapper = new ObjectMapper(); - - final MetadataSourceInfo sourceInfo = new MetadataSourceInfo(); - sourceInfo.setOwner(owner); - if (groupOwner != null) { - sourceInfo.setGroupOwner(groupOwner); - } - final AccessManager accessManager = context.getBean(AccessManager.class); - final boolean isOwner = accessManager.isOwner(context, sourceInfo); - final HashSet operations; - boolean canEdit = false; - if (isOwner) { - operations = Sets.newHashSet(Arrays.asList(ReservedOperation.values())); - if (owner != null) { - doc.put("ownerId", owner.intValue()); - } - } else { - final Collection groups = - accessManager.getUserGroups(context.getUserSession(), context.getIpAddress(), false); - final Collection editingGroups = - accessManager.getUserGroups(context.getUserSession(), context.getIpAddress(), true); - operations = Sets.newHashSet(); - for (ReservedOperation operation : ReservedOperation.values()) { - final JsonNode operationNodes = doc.get("_source").get(Geonet.IndexFieldNames.OP_PREFIX + operation.getId()); - if (operationNodes != null) { - ArrayNode opFields = operationNodes.isArray() ? (ArrayNode) operationNodes : objectMapper.createArrayNode().add(operationNodes); - if (opFields != null) { - for (JsonNode field : opFields) { - final int groupId = field.asInt(); - if (operation == ReservedOperation.editing - && !canEdit - && editingGroups.contains(groupId)) { - canEdit = true; - } - - if (groups.contains(groupId)) { - operations.add(operation); - } - } - } - } - } - } - doc.put(Edit.Info.Elem.EDIT, isOwner || canEdit); - doc.put(Edit.Info.Elem.REVIEW, - id != null ? accessManager.hasReviewPermission(context, id) : false); - doc.put(Edit.Info.Elem.OWNER, isOwner); - doc.put(Edit.Info.Elem.IS_PUBLISHED_TO_ALL, hasOperation(doc, ReservedGroup.all, ReservedOperation.view)); - addReservedOperation(doc, operations, ReservedOperation.view); - addReservedOperation(doc, operations, ReservedOperation.notify); - addReservedOperation(doc, operations, ReservedOperation.download); - addReservedOperation(doc, operations, ReservedOperation.dynamic); - addReservedOperation(doc, operations, ReservedOperation.featured); - - if (!operations.contains(ReservedOperation.download)) { - doc.put(Edit.Info.Elem.GUEST_DOWNLOAD, hasOperation(doc, ReservedGroup.guest, ReservedOperation.download)); - } - } + @Autowired + private EsQueryProcessor queryPreprocessor; - private static void addReservedOperation(ObjectNode doc, HashSet operations, - ReservedOperation kind) { - doc.put(kind.name(), operations.contains(kind)); - } + @Autowired + private EsResponseContentTypeValidator contentTypeValidator; - private static boolean hasOperation(ObjectNode doc, ReservedGroup group, ReservedOperation operation) { - ObjectMapper objectMapper = new ObjectMapper(); - int groupId = group.getId(); - final JsonNode operationNodes = doc.get("_source").get(Geonet.IndexFieldNames.OP_PREFIX + operation.getId()); - if (operationNodes != null) { - ArrayNode opFields = operationNodes.isArray() ? (ArrayNode) operationNodes : objectMapper.createArrayNode().add(operationNodes); - if (opFields != null) { - for (JsonNode field : opFields) { - if (groupId == field.asInt()) { - return true; - } - } - } - } - return false; + public EsHTTPProxy() { } - @io.swagger.v3.oas.annotations.Operation( summary = "Execute a search query and get back search hits that match the query.", description = "The search API execute a search query with a JSON request body. For more information see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html for search parameters, and https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html JSON Query DSL.") @@ -306,8 +131,7 @@ private static boolean hasOperation(ObjectNode doc, ReservedGroup group, Reserve public void search( @RequestParam(defaultValue = SelectionManager.SELECTION_BUCKET) String bucket, - @Parameter(description = "Type of related resource. If none, no associated resource returned.", - required = false + @Parameter(description = "Type of related resource. If none, no associated resource returned." ) @RequestParam(name = "relatedType", defaultValue = "") RelatedItemType[] relatedTypes, @@ -324,7 +148,7 @@ public void search( })) String body) throws Exception { ServiceContext context = ApiUtils.createServiceContext(request); - call(context, httpSession, request, response, SEARCH_ENDPOINT, body, bucket, relatedTypes); + call(context, httpSession, request, response, EsSearchEndpoints.SEARCH_ENDPOINT.toString(), body, bucket, relatedTypes); } @@ -348,9 +172,7 @@ public void search( public void msearch( @RequestParam(defaultValue = SelectionManager.SELECTION_METADATA) String bucket, - @Parameter(description = "Type of related resource. If none, no associated resource returned.", - required = false - ) + @Parameter(description = "Type of related resource. If none, no associated resource returned.") @RequestParam(name = "relatedType", defaultValue = "") RelatedItemType[] relatedTypes, @Parameter(hidden = true) @@ -366,7 +188,7 @@ public void msearch( })) String body) throws Exception { ServiceContext context = ApiUtils.createServiceContext(request); - call(context, httpSession, request, response, MULTISEARCH_ENDPOINT, body, bucket, relatedTypes); + call(context, httpSession, request, response, EsSearchEndpoints.MULTISEARCH_ENDPOINT.toString(), body, bucket, relatedTypes); } @@ -419,158 +241,20 @@ private void call(ServiceContext context, HttpSession httpSession, HttpServletRe final String url = client.getServerUrl() + "/" + defaultIndex + "/" + endPoint + "?"; // Make query on multiple indices // final String url = client.getServerUrl() + "/" + defaultIndex + ",gn-features/" + endPoint + "?"; - if (SEARCH_ENDPOINT.equals(endPoint) || MULTISEARCH_ENDPOINT.equals(endPoint)) { - UserSession session = context.getUserSession(); - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode nodeQuery = objectMapper.readTree(body); - - // multisearch support - final MappingIterator mappingIterator = objectMapper.readerFor(JsonNode.class).readValues(body); - StringBuffer requestBody = new StringBuffer(); - while (mappingIterator.hasNextValue()) { - JsonNode node = (JsonNode) mappingIterator.nextValue(); - final JsonNode indexNode = node.get("index"); - if (indexNode != null) { - ((ObjectNode) node).put("index", defaultIndex); - } else { - JsonNode queryNode = node.get("query"); - - addFilterToQuery(context, objectMapper, node); - if (selectionBucket != null) { - // Multisearch are not supposed to work with a bucket. - // Only one request is store in session - session.setProperty(Geonet.Session.SEARCH_REQUEST + selectionBucket, node); - } - final JsonNode sourceNode = node.get("_source"); - if (sourceNode != null) { - if (sourceNode.isArray()) { - addRequiredField((ArrayNode) sourceNode); - } else { - final JsonNode sourceIncludes = sourceNode.get("includes"); - if (sourceIncludes != null && sourceIncludes.isArray()) { - addRequiredField((ArrayNode) sourceIncludes); - } - } - } - } - requestBody.append(node).append(System.lineSeparator()); - } + if (EsSearchEndpoints.SEARCH_ENDPOINT.toString().equals(endPoint) || + EsSearchEndpoints.MULTISEARCH_ENDPOINT.toString().equals(endPoint)) { + String requestBody = queryPreprocessor.process(context, body, selectionBucket); + handleRequest(context, httpSession, request, response, url, endPoint, - requestBody.toString(), true, selectionBucket, relatedTypes); + requestBody, true, selectionBucket, relatedTypes); } else { handleRequest(context, httpSession, request, response, url, endPoint, body, true, selectionBucket, relatedTypes); } } - /** - * {@link #addUserInfo(ObjectNode, ServiceContext)} - * rely on fields from the index. Add them to the source. - */ - private void addRequiredField(ArrayNode source) { - source.add("op*"); - source.add(Geonet.IndexFieldNames.SCHEMA); - source.add(Geonet.IndexFieldNames.GROUP_OWNER); - source.add(Geonet.IndexFieldNames.OWNER); - source.add(Geonet.IndexFieldNames.ID); - } - private void addFilterToQuery(ServiceContext context, - ObjectMapper objectMapper, - JsonNode esQuery) throws Exception { - - // Build filter node - String esFilter = buildQueryFilter(context, - "", - esQuery.toString().contains("\"draft\":") - || esQuery.toString().contains("+draft:") - || esQuery.toString().contains("-draft:")); - JsonNode nodeFilter = objectMapper.readTree(esFilter); - - JsonNode queryNode = esQuery.get("query"); - - // Defensive: if no "query", create a bool { must: match_all, filter: nodeFilter } - if (queryNode == null || queryNode.isNull()) { - ObjectNode boolNode = objectMapper.createObjectNode(); - // prefer must = match_all object (same shape as existing code) - ObjectNode matchAll = objectMapper.createObjectNode(); - matchAll.putObject("match_all"); - boolNode.set("must", matchAll); - boolNode.set("filter", nodeFilter); - ((ObjectNode) esQuery).set("query", objectMapper.createObjectNode().set("bool", boolNode)); - return; - } - - // Try to find the boolean node where to insert the filter. - // Prefer function_score.query.bool if present because function_score - // needs the filter to be applied to the inner query used for scoring. - JsonNode functionScoreNode = queryNode.get("function_score"); - if (functionScoreNode != null && functionScoreNode.isObject()) { - JsonNode innerQuery = functionScoreNode.get("query"); - if (innerQuery == null || innerQuery.isNull()) { - // create function_score.query.bool with only the filter - ObjectNode boolNode = objectMapper.createObjectNode(); - boolNode.set("filter", nodeFilter); - ((ObjectNode) functionScoreNode).set("query", objectMapper.createObjectNode().set("bool", boolNode)); - return; - } - - JsonNode innerBool = innerQuery.get("bool"); - if (innerBool != null && innerBool.isObject()) { - insertFilter((ObjectNode) innerBool, nodeFilter); - return; - } - - // innerQuery exists but isn't a bool -> wrap it into a bool { must: , filter: } - ObjectNode newBool = objectMapper.createObjectNode(); - newBool.set("must", innerQuery.deepCopy()); - newBool.set("filter", nodeFilter); - ((ObjectNode) functionScoreNode).set("query", objectMapper.createObjectNode().set("bool", newBool)); - return; - } - - // Top-level bool - JsonNode boolNode = queryNode.get("bool"); - if (boolNode != null && boolNode.isObject()) { - insertFilter((ObjectNode) boolNode, nodeFilter); - return; - } - - // Other query shapes: wrap existing query into a bool { must: , filter: nodeFilter } - ObjectNode copy = queryNode.deepCopy(); - ObjectNode objectNodeBool = objectMapper.createObjectNode(); - objectNodeBool.set("must", copy); - objectNodeBool.set("filter", nodeFilter); - - // Replace the existing "query" content with the new bool - ((ObjectNode) queryNode).removeAll(); - ((ObjectNode) queryNode).set("bool", objectNodeBool); - } - - private void insertFilter(ObjectNode objectNode, JsonNode nodeFilter) { - JsonNode filter = objectNode.get("filter"); - if (filter == null || filter.isNull()) { - objectNode.set("filter", nodeFilter); - } else if (filter.isArray()) { - ((ArrayNode) filter).add(nodeFilter); - } else { - // existing filter is an object (or other non-array) -> convert to array preserving both - ArrayNode arr = JsonNodeFactory.instance.arrayNode(); - arr.add(filter); - arr.add(nodeFilter); - objectNode.set("filter", arr); - } - } - /** - * Add search privilege criteria to a query. - */ - protected String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) throws Exception { - return String.format(filterTemplate, - EsFilterBuilder.build(context, type, isSearchingForDraft, node)); - - } - - private String buildDocTypeFilter(String type) { - return "documentType:" + type; + protected HttpURLConnection openConnection(String sUrl) throws IOException { + return (HttpURLConnection) new URL(sUrl).openConnection(); } private void handleRequest(ServiceContext context, @@ -584,11 +268,9 @@ private void handleRequest(ServiceContext context, String selectionBucket, RelatedItemType[] relatedTypes) throws Exception { try { - URL url = new URL(sUrl); - // open communication between proxy and final host // all actions before the connection can be taken now - HttpURLConnection connectionWithFinalHost = (HttpURLConnection) url.openConnection(); + HttpURLConnection connectionWithFinalHost = openConnection(sUrl); try { connectionWithFinalHost.setRequestMethod(request.getMethod()); @@ -623,34 +305,14 @@ private void handleRequest(ServiceContext context, "Error is: %s.\nRequest:\n%s.\nError:\n%s.", connectionWithFinalHost.getResponseMessage(), requestBody, - IOUtils.toString(errorDetails) + IOUtils.toString(errorDetails, Charset.defaultCharset()) )); return; } // get content type String contentType = connectionWithFinalHost.getContentType(); - if (contentType == null) { - response.sendError(HttpServletResponse.SC_FORBIDDEN, - "Host url has been validated by proxy but content type given by remote host is null"); - return; - } - - // content type has to be valid - if (!isContentTypeValid(contentType)) { - if (connectionWithFinalHost.getResponseMessage() != null) { - if (connectionWithFinalHost.getResponseMessage().equalsIgnoreCase("Not Found")) { - // content type was not valid because it was a not found page (text/html) - response.sendError(HttpServletResponse.SC_NOT_FOUND, "Remote host not found"); - return; - } - } - - response.sendError(HttpServletResponse.SC_FORBIDDEN, - "The content type of the remote host's response \"" + contentType - + "\" is not allowed by the proxy rules"); - return; - } + contentTypeValidator.validateContentType(connectionWithFinalHost, response, contentType); // copy headers from the remote server's response to the response to send to the client copyHeadersFromConnectionToResponse(response, connectionWithFinalHost, proxyHeadersIgnoreList); @@ -681,19 +343,19 @@ private void handleRequest(ServiceContext context, } try { - processResponse(context, httpSession, streamFromServer, streamToClient, endPoint, selectionBucket, addPermissions, relatedTypes); + responseProcessor.processResponse(context, httpSession, streamFromServer, streamToClient, endPoint, selectionBucket, addPermissions, relatedTypes); streamToClient.flush(); } finally { IOUtils.closeQuietly(streamFromServer); } } catch (Exception ex) { - ex.printStackTrace(); + LOGGER.error(ex.getMessage(), ex); } finally { connectionWithFinalHost.disconnect(); } } catch (IOException e) { // connection problem with the host - e.printStackTrace(); + LOGGER.error(e.getMessage(), e); throw new Exception( String.format("Failed to request Es at URL %s. " + @@ -703,81 +365,6 @@ private void handleRequest(ServiceContext context, } } - private void processResponse(ServiceContext context, HttpSession httpSession, - InputStream streamFromServer, OutputStream streamToClient, - String endPoint, - String bucket, - boolean addPermissions, - RelatedItemType[] relatedTypes) throws Exception { - JsonParser parser = JsonStreamUtils.jsonFactory.createParser(streamFromServer); - JsonGenerator generator = JsonStreamUtils.jsonFactory.createGenerator(streamToClient); - parser.nextToken(); //Go to the first token - - final Set selections = (addPermissions ? - SelectionManager.getManager(ApiUtils.getUserSession(httpSession)).getSelection(bucket) : new HashSet<>()); - - if (endPoint.equals(SEARCH_ENDPOINT)) { - JsonStreamUtils.addInfoToDocs(parser, generator, doc -> { - if (addPermissions) { - addUserInfo(doc, context); - addSelectionInfo(doc, selections); - } - - if ((relatedTypes != null ) && (relatedTypes.length > 0)) { - addRelatedTypes(doc, relatedTypes, context); - } - - if (doc.has("_source")) { - ObjectNode sourceNode = (ObjectNode) doc.get("_source"); - - if (sourceNode.has(Geonet.IndexFieldNames.SCHEMA)) { - String metadataSchema = sourceNode.get(Geonet.IndexFieldNames.SCHEMA).asText(); - try { - MetadataSchema mds = schemaManager.getSchema(metadataSchema); - - // Apply metadata schema filters to remove non-allowed fields - processMetadataSchemaFilters(context, mds, doc); - } catch (IllegalArgumentException e) { - LOGGER.error("Failed to load metadata schema for {}. Error is: {}", - getSourceString(doc, Geonet.IndexFieldNames.UUID), - e.getMessage() - ); - } - } - - // Remove fields with privileges info - for (ReservedOperation o : ReservedOperation.values()) { - sourceNode.remove("op" + o.getId()); - } - - } - }); - } else { - JsonStreamUtils.addInfoToDocsMSearch(parser, generator, doc -> { - if (addPermissions) { - addUserInfo(doc, context); - addSelectionInfo(doc, selections); - } - - if ((relatedTypes != null ) && (relatedTypes.length > 0)) { - addRelatedTypes(doc, relatedTypes, context); - } - - // Remove fields with privileges info - if (doc.has("_source")) { - ObjectNode sourceNode = (ObjectNode) doc.get("_source"); - - for (ReservedOperation o : ReservedOperation.values()) { - sourceNode.remove("op" + o.getId()); - } - } - }); - } - - generator.flush(); - generator.close(); - } - /** * Gets the encoding of the content sent by the remote host: extracts the * content-encoding header @@ -843,8 +430,8 @@ private void copyHeadersFromConnectionToResponse(HttpServletResponse response, H */ protected void copyHeadersToConnection(HttpServletRequest request, HttpURLConnection uc) { - for (Enumeration enumHeader = request.getHeaderNames(); enumHeader.hasMoreElements(); ) { - String headerName = (String) enumHeader.nextElement(); + for (Enumeration enumHeader = request.getHeaderNames(); enumHeader.hasMoreElements(); ) { + String headerName = enumHeader.nextElement(); String headerValue = request.getHeader(headerName); // copy every header except host @@ -855,132 +442,4 @@ protected void copyHeadersToConnection(HttpServletRequest request, HttpURLConnec } } } - - /** - * Check if the content type is accepted by the proxy - * - * @return true: valid; false: not valid - */ - protected boolean isContentTypeValid(final String contentType) { - - // focus only on type, not on the text encoding - String type = contentType.split(";")[0]; - for (String validTypeContent : EsHTTPProxy._validContentTypes) { - if (validTypeContent.equals(type)) { - return true; - } - } - return false; - } - - - /** - * Process the metadata schema filters to filter out from the Elasticsearch response - * the elements defined in the metadata schema filters. - * - * It uses a jsonpath to filter the elements, typically is configured with the following jsonpath, to - * filter the ES object elements with an attribute nilReason = 'withheld'. - * - * $.*[?(@.nilReason == 'withheld')] - * - * The metadata index process, has to define this attribute. Any element that requires to be filtered, should be - * defined as an object in Elasticsearch. - * - * Example for contacts: - * - * - * ... - * - * - * - * - * { - * ... - * "address":"" - * - * ,"nilReason": "withheld" - * - * - * @param mds - * @param doc - * @throws JsonProcessingException - */ - private void processMetadataSchemaFilters(ServiceContext context, MetadataSchema mds, ObjectNode doc) throws JsonProcessingException, SQLException { - if (!doc.has("_source")) { - return; - } - - ObjectMapper mapper = new ObjectMapper(); - ObjectNode sourceNode = (ObjectNode) doc.get("_source"); - - MetadataSchemaOperationFilter authenticatedFilter = mds.getOperationFilter(MetadataOperationFilterType.authenticated.name()); - - List jsonpathFilters = new ArrayList<>(); - - if (authenticatedFilter != null && !context.getUserSession().isAuthenticated()) { - jsonpathFilters.add(authenticatedFilter.getJsonpath()); - } - //do the same for groupOwner - MetadataSchemaOperationFilter groupOwnerFilter = mds.getOperationFilter(MetadataOperationFilterType.groupOwner.name()); - - if (groupOwnerFilter != null) { - if (context.getUserSession().getProfile() != Profile.Administrator) { - List userGroups = AccessManager.getGroups(context.getUserSession(), Profile.Editor); - Integer groupOwner = getSourceInteger(doc, Geonet.IndexFieldNames.GROUP_OWNER); - boolean isGroupOwner = groupOwner != null && userGroups.contains(groupOwner); - - if (!isGroupOwner) { - jsonpathFilters.add(groupOwnerFilter.getJsonpath()); - } - } - } - - MetadataSchemaOperationFilter editFilter = mds.getOperationFilter(ReservedOperation.editing); - - if (editFilter != null) { - boolean canEdit = doc.get("edit").asBoolean(); - - if (!canEdit) { - jsonpathFilters.add(editFilter.getJsonpath()); - } - } - - MetadataSchemaOperationFilter downloadFilter = mds.getOperationFilter(ReservedOperation.download); - if (downloadFilter != null) { - boolean canDownload = doc.get("download").asBoolean(); - - if (!canDownload) { - jsonpathFilters.add(downloadFilter.getJsonpath()); - } - } - - MetadataSchemaOperationFilter dynamicFilter = mds.getOperationFilter(ReservedOperation.dynamic); - if (dynamicFilter != null) { - boolean canDynamic = doc.get("dynamic").asBoolean(); - - if (!canDynamic) { - jsonpathFilters.add(dynamicFilter.getJsonpath()); - } - } - - JsonNode actualObj = filterResponseElements(mapper, sourceNode, jsonpathFilters); - if (actualObj != null) { - doc.set("_source", actualObj); - } - } - private JsonNode filterResponseElements(ObjectMapper mapper, ObjectNode sourceNode, List jsonPathFilters) throws JsonProcessingException { - DocumentContext jsonContext = JsonPath.parse(sourceNode.toPrettyString()); - - for(String jsonPath : jsonPathFilters) { - if (StringUtils.isNotBlank(jsonPath)) { - try { - jsonContext = jsonContext.delete(jsonPath); - } catch (PathNotFoundException ex) { - // The node to remove is not returned in the response, ignore the error - } - } - } - - return mapper.readTree(jsonContext.jsonString()); - } } diff --git a/services/src/main/java/org/fao/geonet/api/es/EsResponseContentTypeValidator.java b/services/src/main/java/org/fao/geonet/api/es/EsResponseContentTypeValidator.java new file mode 100644 index 000000000000..df2ca3277016 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/EsResponseContentTypeValidator.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es; + +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +@Component +public class EsResponseContentTypeValidator { + private static final Set VALID_CONTENT_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "application/json", "text/plain" + ))); + + private static final String NOT_FOUND_MESSAGE = "Not Found"; + + public void validateContentType(HttpURLConnection connectionWithFinalHost, HttpServletResponse response, String contentType) throws IOException { + if (contentType == null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "Host url has been validated by proxy but content type given by remote host is null"); + return; + } + + // content type has to be valid + if (!isContentTypeValid(contentType)) { + String responseMessage = connectionWithFinalHost.getResponseMessage(); + if (NOT_FOUND_MESSAGE.equalsIgnoreCase(responseMessage)) { + // content type was not valid because it was a not found page (text/html) + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Remote host not found"); + } else { + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "The content type of the remote host's response \"" + contentType + + "\" is not allowed by the proxy rules"); + } + } + } + + + /** + * Check if the content type is accepted by the proxy + * + * @return true: valid; false: not valid + */ + public boolean isContentTypeValid(final String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return false; + } + + // focus only on type, not on the text encoding + String type = contentType.split(";")[0].trim().toLowerCase(); + return VALID_CONTENT_TYPES.contains(type); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/EsSearchEndpoints.java b/services/src/main/java/org/fao/geonet/api/es/EsSearchEndpoints.java new file mode 100644 index 000000000000..0e69c8ee958f --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/EsSearchEndpoints.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es; + +public enum EsSearchEndpoints { + SEARCH_ENDPOINT("_search"), + MULTISEARCH_ENDPOINT("_msearch"); + + private final String endPoint; + + EsSearchEndpoints(final String endPoint) { + this.endPoint = endPoint; + } + @Override + public String toString() { + return endPoint; + } + +} diff --git a/services/src/main/java/org/fao/geonet/api/es/ObjectNodeUtils.java b/services/src/main/java/org/fao/geonet/api/es/ObjectNodeUtils.java new file mode 100644 index 000000000000..b4e0732b1e08 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/ObjectNodeUtils.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class ObjectNodeUtils { + public final static String SOURCE_NODE = "_source"; + + private ObjectNodeUtils() { + // Don't allow to instantiate it + } + + public static String getSourceString(ObjectNode node, String name) { + if (!node.has(SOURCE_NODE)) { + return null; + } + final JsonNode sub = node.get(SOURCE_NODE).get(name); + return sub != null ? sub.asText() : null; + } + + public static Integer getSourceInteger(ObjectNode node, String name) { + if (!node.has(SOURCE_NODE)) { + return null; + } + final JsonNode sub = node.get(SOURCE_NODE).get(name); + return sub != null ? sub.asInt() : null; + } + + public static ObjectNode getSourceNode(ObjectNode doc) { + if (!doc.has(ObjectNodeUtils.SOURCE_NODE)) { + return null; + } + + return (ObjectNode) doc.get(SOURCE_NODE); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilder.java b/services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilder.java new file mode 100644 index 000000000000..0fb8e854b42c --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilder.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.query; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.NodeInfo; +import org.fao.geonet.kernel.search.EsFilterBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Iterator; + +@Component +public class EsQueryFilterBuilder { + private static final String QUERY = "query"; + private static final String BOOL = "bool"; + private static final String FILTER = "filter"; + private static final String MUST = "must"; + private static final String MATCH_ALL = "match_all"; + private static final String FUNCTION_SCORE = "function_score"; + private static final String GLOBAL = "global"; + private static final String AGGS = "aggs"; + private static final String AGGREGATIONS = "aggregations"; + + /** + * Privileges filter only allows + * * op0 (ie. view operation) contains one of the ids of your groups + */ + private static final String FILTER_TEMPLATE = " {\n" + + " \t\"query_string\": {\n" + + " \t\t\"query\": \"%s\"\n" + + " \t}\n" + + "}"; + + + @Autowired + NodeInfo node; + + public void addFilterToQuery(ServiceContext context, + ObjectMapper objectMapper, + JsonNode esQuery) throws Exception { + + // Build filter node + boolean isSearchingForDraft = isSearchingForDraft(esQuery); + String esFilter = buildQueryFilter(context, "", isSearchingForDraft); + JsonNode nodeFilter = objectMapper.readTree(esFilter); + + JsonNode queryNode = esQuery.get(QUERY); + + // Replace any "global" aggregation with a "filter" aggregation scoped to + // the ACL filter. The ES "global" bucket ignores the query context entirely + // and would otherwise expose aggregate statistics from restricted records. + // Must run after nodeFilter is built, before any branch exits early via return. + for (String aggsKey : new String[]{AGGS, AGGREGATIONS}) { + JsonNode aggsNode = esQuery.get(aggsKey); + if (aggsNode != null && aggsNode.isObject()) { + replaceGlobalAggregations((ObjectNode) aggsNode, nodeFilter); + } + } + + // Defensive: if no "query", create a bool { must: match_all, filter: nodeFilter } + if (queryNode == null || queryNode.isNull() + || (queryNode.isObject() && queryNode.isEmpty())) { + ObjectNode boolNode = objectMapper.createObjectNode(); + // prefer must = match_all object (same shape as existing code) + ObjectNode matchAll = objectMapper.createObjectNode(); + matchAll.putObject(MATCH_ALL); + boolNode.set(MUST, matchAll); + boolNode.set(FILTER, nodeFilter); + ((ObjectNode) esQuery).set(QUERY, objectMapper.createObjectNode().set(BOOL, boolNode)); + return; + } + + // Try to find the boolean node where to insert the filter. + // Prefer function_score.query.bool if present because function_score + // needs the filter to be applied to the inner query used for scoring. + JsonNode functionScoreNode = queryNode.get(FUNCTION_SCORE); + if (functionScoreNode != null && functionScoreNode.isObject()) { + JsonNode innerQuery = functionScoreNode.get(QUERY); + if (innerQuery == null || innerQuery.isNull()) { + // create function_score.query.bool with only the filter + ObjectNode boolNode = objectMapper.createObjectNode(); + boolNode.set(FILTER, nodeFilter); + ((ObjectNode) functionScoreNode).set(QUERY, objectMapper.createObjectNode().set(BOOL, boolNode)); + return; + } + + JsonNode innerBool = innerQuery.get(BOOL); + if (innerBool != null && innerBool.isObject()) { + insertFilter((ObjectNode) innerBool, nodeFilter); + return; + } + + // innerQuery exists but isn't a bool -> wrap it into a bool { must: , filter: } + ObjectNode newBool = objectMapper.createObjectNode(); + newBool.set(MUST, innerQuery.deepCopy()); + newBool.set(FILTER, nodeFilter); + ((ObjectNode) functionScoreNode).set(QUERY, objectMapper.createObjectNode().set(BOOL, newBool)); + return; + } + + // Top-level bool + JsonNode boolNode = queryNode.get(BOOL); + if (boolNode != null && boolNode.isObject()) { + insertFilter((ObjectNode) boolNode, nodeFilter); + return; + } + + // Other query shapes: wrap existing query into a bool { must: , filter: nodeFilter } + ObjectNode copy = queryNode.deepCopy(); + ObjectNode objectNodeBool = objectMapper.createObjectNode(); + objectNodeBool.set(MUST, copy); + objectNodeBool.set(FILTER, nodeFilter); + + // Replace the existing "query" content with the new bool + ((ObjectNode) queryNode).removeAll(); + ((ObjectNode) queryNode).set(BOOL, objectNodeBool); + } + + private boolean isSearchingForDraft(JsonNode node) { + if (node.isObject()) { + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String fieldName = fieldNames.next(); + if (fieldName.equals("draft")) { + return true; + } + if (isSearchingForDraft(node.get(fieldName))) { + return true; + } + } + } else if (node.isArray()) { + for (JsonNode entry : node) { + if (isSearchingForDraft(entry)) { + return true; + } + } + } else if (node.isTextual()) { + String text = node.asText(); + return text.contains("\"draft\":") || text.contains("+draft:") || text.contains("-draft:"); + } + return false; + } + + private void replaceGlobalAggregations(ObjectNode aggsNode, JsonNode aclFilter) { + aggsNode.fields().forEachRemaining(entry -> { + JsonNode aggDef = entry.getValue(); + if (!aggDef.isObject()) { + return; + } + ObjectNode aggDefObj = (ObjectNode) aggDef; + if (aggDefObj.has(GLOBAL)) { + // "global" ignores the query scope; swap it for a filter-scoped bucket. + aggDefObj.remove(GLOBAL); + aggDefObj.set(FILTER, aclFilter); + } + // Recurse into nested sub-aggregations. + for (String subKey : new String[]{AGGS, AGGREGATIONS}) { + JsonNode sub = aggDefObj.get(subKey); + if (sub != null && sub.isObject()) { + replaceGlobalAggregations((ObjectNode) sub, aclFilter); + } + } + }); + } + + /** + * Add search privilege criteria to a query. + */ + private String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) throws Exception { + return String.format(FILTER_TEMPLATE, + EsFilterBuilder.build(context, type, isSearchingForDraft, node)); + + } + + private void insertFilter(ObjectNode objectNode, JsonNode nodeFilter) { + JsonNode filter = objectNode.get(FILTER); + if (filter == null || filter.isNull()) { + objectNode.set(FILTER, nodeFilter); + } else if (filter.isArray()) { + ((ArrayNode) filter).add(nodeFilter); + } else { + // existing filter is an object (or other non-array) -> convert to array preserving both + ArrayNode arr = objectNode.putArray(FILTER); + arr.add(filter); + arr.add(nodeFilter); + } + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryProcessor.java new file mode 100644 index 000000000000..3fc15d0dae8b --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/query/EsQueryProcessor.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.query; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.UserSession; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Geonet; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class EsQueryProcessor { + @Value("${es.index.records:gn-records}") + private String defaultIndex; + + @Autowired + private EsQueryFilterBuilder queryFilterBuilder; + + public String process(ServiceContext context, String body, String selectionBucket) throws Exception { + UserSession session = context.getUserSession(); + ObjectMapper objectMapper = new ObjectMapper(); + + // multisearch support + final MappingIterator mappingIterator = objectMapper.readerFor(JsonNode.class).readValues(body); + StringBuffer requestBody = new StringBuffer(); + while (mappingIterator.hasNextValue()) { + JsonNode node = (JsonNode) mappingIterator.nextValue(); + final JsonNode indexNode = node.get("index"); + if (indexNode != null) { + ((ObjectNode) node).put("index", defaultIndex); + } else { + queryFilterBuilder.addFilterToQuery(context, objectMapper, node); + if (selectionBucket != null) { + // Multisearch are not supposed to work with a bucket. + // Only one request is store in session + session.setProperty(Geonet.Session.SEARCH_REQUEST + selectionBucket, node); + } + final JsonNode sourceNode = node.get(ObjectNodeUtils.SOURCE_NODE); + if (sourceNode != null) { + if (sourceNode.isArray()) { + addRequiredField((ArrayNode) sourceNode); + } else { + final JsonNode sourceIncludes = sourceNode.get("includes"); + if (sourceIncludes != null && sourceIncludes.isArray()) { + addRequiredField((ArrayNode) sourceIncludes); + } + } + } + } + requestBody.append(node).append(System.lineSeparator()); + } + + return requestBody.toString(); + } + + private void addRequiredField(ArrayNode source) { + source.add("op*"); + source.add(Geonet.IndexFieldNames.SCHEMA); + source.add(Geonet.IndexFieldNames.GROUP_OWNER); + source.add(Geonet.IndexFieldNames.OWNER); + source.add(Geonet.IndexFieldNames.ID); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessor.java new file mode 100644 index 000000000000..60e4eee8537d --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessor.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import jeeves.server.context.ServiceContext; +import org.apache.commons.lang.StringUtils; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.Profile; +import org.fao.geonet.domain.ReservedOperation; +import org.fao.geonet.kernel.AccessManager; +import org.fao.geonet.kernel.SchemaManager; +import org.fao.geonet.kernel.schema.MetadataOperationFilterType; +import org.fao.geonet.kernel.schema.MetadataSchema; +import org.fao.geonet.kernel.schema.MetadataSchemaOperationFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Process an Elasticsearch document to filter out the elements defined in the metadata schema filters. + * + * It uses a jsonpath to filter the elements, typically is configured with the following jsonpath, to + * filter the ES object elements with an attribute nilReason = 'withheld'. + * + * $.*[?(@.nilReason == 'withheld')] + * + * The metadata index process, has to define this attribute. Any element that requires to be filtered, should be + * defined as an object in Elasticsearch. + * + * Example for contacts: + * + * + * ... + * + * + * + * + * { + * ... + * "address":"" + * + * ,"nilReason": "withheld" + * + */ +@Component +public class EsDocumentMetadataFiltersProcessor implements EsDocumentProcessor { + private static final Logger LOGGER = LoggerFactory.getLogger(Geonet.INDEX_ENGINE); + + @Autowired + private SchemaManager schemaManager; + + private final ObjectMapper mapper = new ObjectMapper(); + + private final Configuration configuration = Configuration.builder() + .jsonProvider(new JacksonJsonNodeJsonProvider(mapper)) + .mappingProvider(new JacksonMappingProvider(mapper)) + .build(); + + @Override + public void process(ObjectNode doc, ServiceContext context, Map parameters) throws Exception { + processMetadataSchemaFilters(context, doc); + } + + private void processMetadataSchemaFilters(ServiceContext context, ObjectNode doc) throws Exception { + ObjectNode sourceNode = ObjectNodeUtils.getSourceNode(doc); + if (sourceNode == null) { + return; + } + + MetadataSchema mds; + + try { + String metadataSchema = sourceNode.get(Geonet.IndexFieldNames.SCHEMA).asText(); + mds = schemaManager.getSchema(metadataSchema); + } catch (IllegalArgumentException e) { + LOGGER.error("Failed to load metadata schema for {}. Error is: {}", + ObjectNodeUtils.getSourceString(doc, Geonet.IndexFieldNames.UUID), + e.getMessage() + ); + + return; + } + + MetadataSchemaOperationFilter authenticatedFilter = mds.getOperationFilter(MetadataOperationFilterType.authenticated.name()); + + List jsonpathFilters = new ArrayList<>(); + + if (authenticatedFilter != null && !context.getUserSession().isAuthenticated()) { + jsonpathFilters.add(authenticatedFilter.getJsonpath()); + } + //do the same for groupOwner + MetadataSchemaOperationFilter groupOwnerFilter = mds.getOperationFilter(MetadataOperationFilterType.groupOwner.name()); + + if (groupOwnerFilter != null) { + if (context.getUserSession().getProfile() != Profile.Administrator) { + final AccessManager accessManager = context.getBean(AccessManager.class); + Collection userGroups = accessManager.getUserGroups(context.getUserSession(), context.getIpAddress(), true); + Integer groupOwner = ObjectNodeUtils.getSourceInteger(doc, Geonet.IndexFieldNames.GROUP_OWNER); + boolean isGroupOwner = groupOwner != null && userGroups.contains(groupOwner); + + if (!isGroupOwner) { + jsonpathFilters.add(groupOwnerFilter.getJsonpath()); + } + } + } + + MetadataSchemaOperationFilter editFilter = mds.getOperationFilter(ReservedOperation.editing); + + if (editFilter != null) { + boolean canEdit = doc.get("edit").asBoolean(); + + if (!canEdit) { + jsonpathFilters.add(editFilter.getJsonpath()); + } + } + + MetadataSchemaOperationFilter downloadFilter = mds.getOperationFilter(ReservedOperation.download); + if (downloadFilter != null) { + boolean canDownload = doc.get("download").asBoolean(); + + if (!canDownload) { + jsonpathFilters.add(downloadFilter.getJsonpath()); + } + } + + MetadataSchemaOperationFilter dynamicFilter = mds.getOperationFilter(ReservedOperation.dynamic); + if (dynamicFilter != null) { + boolean canDynamic = doc.get("dynamic").asBoolean(); + + if (!canDynamic) { + jsonpathFilters.add(dynamicFilter.getJsonpath()); + } + } + + JsonNode actualObj = filterResponseElements(mapper, sourceNode, jsonpathFilters); + if (actualObj != null) { + doc.set(ObjectNodeUtils.SOURCE_NODE, actualObj); + } + } + + private JsonNode filterResponseElements(ObjectMapper mapper, ObjectNode sourceNode, List jsonPathFilters) throws JsonProcessingException { + DocumentContext jsonContext = JsonPath.using(configuration).parse(sourceNode); + + for(String jsonPath : jsonPathFilters) { + if (StringUtils.isNotBlank(jsonPath)) { + try { + jsonContext = jsonContext.delete(jsonPath); + } catch (PathNotFoundException ex) { + // The node to remove is not returned in the response, ignore the error + } + } + } + + return jsonContext.json(); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentProcessor.java new file mode 100644 index 000000000000..9d5877cbbb2d --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentProcessor.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; + +import java.util.Map; + +/** + * Interface for Elasticsearch document processors. + */ +public interface EsDocumentProcessor { + /** + * Processes a single Elasticsearch document. + * + * @param doc The document to process. + * @param context The service context. + * @throws Exception If an error occurs during processing. + */ + void process(ObjectNode doc, ServiceContext context, Map parameters) throws Exception; +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessor.java new file mode 100644 index 000000000000..72ad62443c7d --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessor.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.api.records.MetadataUtils; +import org.fao.geonet.api.records.model.related.AssociatedRecord; +import org.fao.geonet.api.records.model.related.RelatedItemType; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.kernel.datamanager.IMetadataUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Processes an Elasticsearch response document to add related metadata information. + */ +@Component +public class EsDocumentRelatedTypesProcessor implements EsDocumentProcessor { + private static final Logger LOGGER = LoggerFactory.getLogger(Geonet.INDEX_ENGINE); + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void process(ObjectNode doc, ServiceContext context, Map parameters) throws Exception { + RelatedItemType[] relatedTypes = (RelatedItemType[]) parameters.get("relatedTypes"); + + if (relatedTypes != null && relatedTypes.length > 0) { + addRelatedTypes(doc, relatedTypes, context); + } + } + + private void addRelatedTypes(ObjectNode doc, + RelatedItemType[] relatedTypes, + ServiceContext context) { + Map> related = null; + try { + if (doc.has(ObjectNodeUtils.SOURCE_NODE) && doc.get(ObjectNodeUtils.SOURCE_NODE).has("id")) { + related = MetadataUtils.getAssociated( + context, + context.getBean(IMetadataUtils.class) + .findOne(doc.get(ObjectNodeUtils.SOURCE_NODE).get("id").asText()), + relatedTypes, 0, 1000); + } + } catch (Exception e) { + LOGGER.warn("Failed to load related types for {}. Error is: {}", + ObjectNodeUtils.getSourceString(doc, Geonet.IndexFieldNames.UUID), + e.getMessage() + ); + } + doc.set("related", mapper.valueToTree(related)); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessor.java new file mode 100644 index 000000000000..1c0f41ea3c57 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessor.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.domain.ReservedOperation; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * Processes an Elasticsearch response document to remove information about metadata privileges. + */ +@Component +public class EsDocumentRemovePrivilegesProcessor implements EsDocumentProcessor { + @Override + public void process(ObjectNode doc, ServiceContext context, Map parameters) throws Exception { + removePrivileges(doc); + } + + private void removePrivileges(ObjectNode doc) { + // Remove fields with privileges info + ObjectNode sourceNode = ObjectNodeUtils.getSourceNode(doc); + if (sourceNode != null) { + for (ReservedOperation o : ReservedOperation.values()) { + sourceNode.remove("op" + o.getId()); + } + } + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessor.java new file mode 100644 index 000000000000..dd1eb80360f2 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessor.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.ApiUtils; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Edit; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.kernel.SelectionManager; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Processes an Elasticsearch response document to add information about the metadata been selected. + */ +@Component +public class EsDocumentSelectionInfoProcessor implements EsDocumentProcessor { + @Override + public void process(ObjectNode doc, ServiceContext context, Map parameters) throws Exception { + + final Set selections = (Set) parameters.get("selections"); + + if (selections != null) { + addSelectionInfo(doc, selections); + } + } + + private void addSelectionInfo(ObjectNode doc, Set selections) { + final String uuid = ObjectNodeUtils.getSourceString(doc, Geonet.IndexFieldNames.UUID); + doc.put(Edit.Info.Elem.SELECTED, selections.contains(uuid)); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessor.java new file mode 100644 index 000000000000..c70a9f6d57d2 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessor.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.Sets; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Edit; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.MetadataSourceInfo; +import org.fao.geonet.domain.ReservedGroup; +import org.fao.geonet.domain.ReservedOperation; +import org.fao.geonet.kernel.AccessManager; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; + +/** + * Processes an Elasticsearch response document to add user and privileges information. + */ +@Component +public class EsDocumentUserInfoProcessor implements EsDocumentProcessor { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void process(ObjectNode doc, ServiceContext context, Map parameters) throws Exception { + addUserInfo(doc, context); + } + + private void addUserInfo(ObjectNode doc, ServiceContext context) throws Exception { + final Integer owner = ObjectNodeUtils.getSourceInteger(doc, Geonet.IndexFieldNames.OWNER); + final Integer groupOwner = ObjectNodeUtils.getSourceInteger(doc, Geonet.IndexFieldNames.GROUP_OWNER); + final String id = ObjectNodeUtils.getSourceString(doc, Geonet.IndexFieldNames.ID); + + final MetadataSourceInfo sourceInfo = new MetadataSourceInfo(); + sourceInfo.setOwner(owner); + if (groupOwner != null) { + sourceInfo.setGroupOwner(groupOwner); + } + final AccessManager accessManager = context.getBean(AccessManager.class); + final boolean isOwner = accessManager.isOwner(context, sourceInfo); + final HashSet operations; + boolean canEdit = false; + if (isOwner) { + operations = Sets.newHashSet(Arrays.asList(ReservedOperation.values())); + if (owner != null) { + doc.put("ownerId", owner.intValue()); + } + } else { + final Collection groups = + accessManager.getUserGroups(context.getUserSession(), context.getIpAddress(), false); + final Collection editingGroups = + accessManager.getUserGroups(context.getUserSession(), context.getIpAddress(), true); + operations = Sets.newHashSet(); + ObjectNode sourceNode = ObjectNodeUtils.getSourceNode(doc); + if (sourceNode != null) { + for (ReservedOperation operation : ReservedOperation.values()) { + final JsonNode operationNodes = sourceNode.get(Geonet.IndexFieldNames.OP_PREFIX + operation.getId()); + if (operationNodes != null) { + ArrayNode opFields = operationNodes.isArray() ? (ArrayNode) operationNodes : objectMapper.createArrayNode().add(operationNodes); + if (opFields != null) { + for (JsonNode field : opFields) { + final int groupId = field.asInt(); + if (operation == ReservedOperation.editing + && !canEdit + && editingGroups.contains(groupId)) { + canEdit = true; + } + + if (groups.contains(groupId)) { + operations.add(operation); + } + } + } + } + } + } + } + doc.put(Edit.Info.Elem.EDIT, isOwner || canEdit); + doc.put(Edit.Info.Elem.REVIEW, + id != null && accessManager.hasReviewPermission(context, id)); + doc.put(Edit.Info.Elem.OWNER, isOwner); + doc.put(Edit.Info.Elem.IS_PUBLISHED_TO_ALL, hasOperation(doc, ReservedGroup.all, ReservedOperation.view)); + addReservedOperation(doc, operations, ReservedOperation.view); + addReservedOperation(doc, operations, ReservedOperation.notify); + addReservedOperation(doc, operations, ReservedOperation.download); + addReservedOperation(doc, operations, ReservedOperation.dynamic); + addReservedOperation(doc, operations, ReservedOperation.featured); + + if (!operations.contains(ReservedOperation.download)) { + doc.put(Edit.Info.Elem.GUEST_DOWNLOAD, hasOperation(doc, ReservedGroup.guest, ReservedOperation.download)); + } + } + + private void addReservedOperation(ObjectNode doc, HashSet operations, + ReservedOperation kind) { + doc.put(kind.name(), operations.contains(kind)); + } + + private boolean hasOperation(ObjectNode doc, ReservedGroup group, ReservedOperation operation) { + ObjectMapper objectMapper = new ObjectMapper(); + int groupId = group.getId(); + ObjectNode sourceNode = ObjectNodeUtils.getSourceNode(doc); + if (sourceNode != null) { + final JsonNode operationNodes = sourceNode.get(Geonet.IndexFieldNames.OP_PREFIX + operation.getId()); + if (operationNodes != null) { + ArrayNode opFields = operationNodes.isArray() ? (ArrayNode) operationNodes : objectMapper.createArrayNode().add(operationNodes); + if (opFields != null) { + for (JsonNode field : opFields) { + if (groupId == field.asInt()) { + return true; + } + } + } + } + } + return false; + } +} diff --git a/services/src/main/java/org/fao/geonet/api/es/processors/response/EsResponseProcessor.java b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsResponseProcessor.java new file mode 100644 index 000000000000..9220b432b396 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/processors/response/EsResponseProcessor.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.ApiUtils; +import org.fao.geonet.api.es.EsSearchEndpoints; +import org.fao.geonet.api.es.JsonStreamUtils; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.api.records.model.related.RelatedItemType; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.kernel.SelectionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpSession; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Processes the Elasticsearch response to filter and add additional information. + */ +@Component +public class EsResponseProcessor { + private static final Logger LOGGER = LoggerFactory.getLogger(Geonet.INDEX_ENGINE); + + @Autowired + private EsDocumentSelectionInfoProcessor esDocumentSelectionInfoProcessor; + + @Autowired + private EsDocumentUserInfoProcessor esDocumentUserInfoProcessor; + + @Autowired + private EsDocumentRelatedTypesProcessor esDocumentRelatedTypesProcessor; + + @Autowired + private EsDocumentMetadataFiltersProcessor esDocumentMetadataFiltersProcessor; + + @Autowired + private EsDocumentRemovePrivilegesProcessor esDocumentRemovePrivilegesProcessor; + + public void processResponse(ServiceContext context, HttpSession httpSession, + InputStream streamFromServer, OutputStream streamToClient, + String endPoint, + String bucket, + boolean addPermissions, + RelatedItemType[] relatedTypes) throws Exception { + JsonParser parser = JsonStreamUtils.jsonFactory.createParser(streamFromServer); + JsonGenerator generator = JsonStreamUtils.jsonFactory.createGenerator(streamToClient); + parser.nextToken(); //Go to the first token + + final Set selections = (addPermissions ? + SelectionManager.getManager(ApiUtils.getUserSession(httpSession)).getSelection(bucket) : new HashSet<>()); + + Map processorParams = new HashMap<>(); + processorParams.put("selections", selections); + processorParams.put("relatedTypes", relatedTypes); + + if (endPoint.equals(EsSearchEndpoints.SEARCH_ENDPOINT.toString())) { + JsonStreamUtils.addInfoToDocs(parser, generator, doc -> { + try { + if (addPermissions) { + esDocumentUserInfoProcessor.process(doc, context, processorParams); + esDocumentSelectionInfoProcessor.process(doc, context, processorParams); + } + + esDocumentRelatedTypesProcessor.process(doc, context, processorParams); + + ObjectNode sourceNode = ObjectNodeUtils.getSourceNode(doc); + if (sourceNode != null) { + if (sourceNode.has(Geonet.IndexFieldNames.SCHEMA)) { + try { + // Apply metadata schema filters to remove non-allowed fields + esDocumentMetadataFiltersProcessor.process(doc, context, processorParams); + } catch (IllegalArgumentException e) { + LOGGER.error("Failed to load metadata schema for {}. Error is: {}", + ObjectNodeUtils.getSourceString(doc, Geonet.IndexFieldNames.UUID), + e.getMessage() + ); + } + } + + // Remove fields with privileges info + esDocumentRemovePrivilegesProcessor.process(doc, context, processorParams); + } + } catch (Exception e) { + LOGGER.error("Error processing document: {}", e.getMessage(), e); + } + }); + } else { + JsonStreamUtils.addInfoToDocsMSearch(parser, generator, doc -> { + try { + if (addPermissions) { + esDocumentUserInfoProcessor.process(doc, context, processorParams); + esDocumentSelectionInfoProcessor.process(doc, context, processorParams); + } + + esDocumentRelatedTypesProcessor.process(doc, context, processorParams); + + // Remove fields with privileges info + esDocumentRemovePrivilegesProcessor.process(doc, context, processorParams); + } catch (Exception e) { + LOGGER.error("Error processing document: {}", e.getMessage(), e); + } + }); + } + + generator.flush(); + generator.close(); + } +} diff --git a/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java b/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java index 8660f17c831e..9aad3a2c4fa7 100644 --- a/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java +++ b/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java @@ -38,7 +38,7 @@ import org.fao.geonet.GeonetContext; import org.fao.geonet.NodeInfo; import org.fao.geonet.api.API; -import org.fao.geonet.api.es.EsHTTPProxy; +import org.fao.geonet.api.es.processors.response.EsDocumentUserInfoProcessor; import org.fao.geonet.api.records.model.related.AssociatedRecord; import org.fao.geonet.api.records.model.related.RelatedItemOrigin; import org.fao.geonet.api.records.model.related.RelatedItemType; @@ -373,7 +373,9 @@ public static Map> getAssociated( JsonNode source = mapper.convertValue(e.source(), JsonNode.class); ObjectNode doc = mapper.createObjectNode(); doc.set("_source", source); - EsHTTPProxy.addUserInfo(doc, context); + + EsDocumentUserInfoProcessor esDocumentUserInfoProcessor = gc.getBean(EsDocumentUserInfoProcessor.class); + esDocumentUserInfoProcessor.process(doc, context, Collections.emptyMap()); Iterator fieldNames = doc.fieldNames(); while (fieldNames.hasNext()) { String field = fieldNames.next(); diff --git a/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java b/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java index 1e2ef9921887..6dfd288c7bc1 100644 --- a/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java +++ b/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java @@ -1,284 +1,275 @@ -/* - * Copyright (C) 2001-2025 Food and Agriculture Organization of the - * United Nations (FAO-UN), United Nations World Food Programme (WFP) - * and United Nations Environment Programme (UNEP) - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or (at - * your option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA - * - * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, - * Rome - Italy. email: geonetwork@osgeo.org - */ - package org.fao.geonet.api.es; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import jeeves.server.UserSession; import jeeves.server.context.ServiceContext; -import org.fao.geonet.ApplicationContextHolder; -import org.fao.geonet.constants.Geonet; -import org.fao.geonet.kernel.SchemaManager; -import org.fao.geonet.kernel.schema.MetadataOperationFilterType; -import org.fao.geonet.kernel.schema.MetadataSchema; -import org.fao.geonet.kernel.schema.MetadataSchemaOperationFilter; -import org.fao.geonet.repository.UserGroupRepository; -import org.junit.Assert; +import org.fao.geonet.api.ApiUtils; +import org.fao.geonet.api.es.processors.query.EsQueryProcessor; +import org.fao.geonet.api.es.processors.response.EsResponseProcessor; +import org.fao.geonet.api.records.model.related.RelatedItemType; +import org.fao.geonet.index.es.EsRestClient; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.data.jpa.domain.Specification; - -import java.lang.reflect.Method; -import java.util.Arrays; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Collections; import java.util.HashMap; +import java.util.List; +import java.util.Map; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@RunWith(MockitoJUnitRunner.class) public class EsHTTPProxyTest { - @Mock - private SchemaManager schemaManager; - - @Mock - private ConfigurableApplicationContext applicationContext; - - @Mock - private UserGroupRepository userGroupRepository; - - @InjectMocks - private EsHTTPProxy esHTTPProxy = new EsHTTPProxy(); - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - ApplicationContextHolder.set(applicationContext); - when(applicationContext.getBean(UserGroupRepository.class)).thenReturn(userGroupRepository); - } - - @Test - public void testProcessMetadataSchemaFiltersGroupOwner() throws Exception { - // 1. Setup - ServiceContext context = new ServiceContext("default", applicationContext, new HashMap<>(), null); - UserSession userSession = spy(new UserSession()); - when(userSession.isAuthenticated()).thenReturn(true); - when(userSession.getUserIdAsInt()).thenReturn(42); - - context.setUserSession(userSession); - - ObjectMapper mapper = new ObjectMapper(); - ObjectNode doc = mapper.createObjectNode(); - ObjectNode source = mapper.createObjectNode(); - source.put(Geonet.IndexFieldNames.GROUP_OWNER, 1); - source.put("someField", "someValue"); - doc.set("_source", source); - doc.put("edit", false); - doc.put("download", false); - doc.put("dynamic", false); - - // Mock MetadataSchema - MetadataSchema mds = mock(MetadataSchema.class); - MetadataSchemaOperationFilter groupOwnerFilter = new MetadataSchemaOperationFilter(null, "$.someField", null); - when(mds.getOperationFilter(MetadataOperationFilterType.groupOwner.name())).thenReturn(groupOwnerFilter); - - // Mock AccessManager.getGroups via UserGroupRepository - // When user is in group 1 - when(userGroupRepository.findGroupIds(any(Specification.class))).thenReturn(Arrays.asList(1)); - - // 2. Call private method using reflection - Method method = EsHTTPProxy.class.getDeclaredMethod("processMetadataSchemaFilters", ServiceContext.class, MetadataSchema.class, ObjectNode.class); - method.setAccessible(true); - method.invoke(esHTTPProxy, context, mds, doc); - - // 3. Assertions for user in group - assertTrue("someField should exist when user is in groupOwner", doc.get("_source").has("someField")); - - // --- Test case where user is NOT in group --- - // When user is NOT in group 1 (e.g. in group 2) - when(userGroupRepository.findGroupIds(any(Specification.class))).thenReturn(Arrays.asList(2)); - - // re-create doc - doc = mapper.createObjectNode(); - source = mapper.createObjectNode(); - source.put(Geonet.IndexFieldNames.GROUP_OWNER, 1); - source.put("someField", "someValue"); - doc.set("_source", source); - doc.put("edit", false); - doc.put("download", false); - doc.put("dynamic", false); - - method.invoke(esHTTPProxy, context, mds, doc); - - assertFalse("someField should be filtered when user is not in groupOwner", doc.get("_source").has("someField")); - } + private static class TestableEsHTTPProxy extends EsHTTPProxy { + private final HttpURLConnection mockConnection; - private static final String QUERY_FILTER = "{\"query_string\":{\"query\":\"(op0:(1)) AND (draft:n OR draft:e)\"}}"; + public TestableEsHTTPProxy(HttpURLConnection mockConnection) { + this.mockConnection = mockConnection; + } - private static class TestableEsHTTPProxy extends EsHTTPProxy { @Override - protected String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) { - return QUERY_FILTER; + protected HttpURLConnection openConnection(String sUrl) throws IOException { + return mockConnection; } } - private final ObjectMapper mapper = new ObjectMapper(); + @Mock + private EsRestClient esRestClient; - private Method getAddFilterToQueryMethod() throws Exception { - Method m = EsHTTPProxy.class.getDeclaredMethod("addFilterToQuery", ServiceContext.class, com.fasterxml.jackson.databind.ObjectMapper.class, com.fasterxml.jackson.databind.JsonNode.class); - m.setAccessible(true); - return m; - } + @Mock + private EsResponseProcessor responseProcessor; - private JsonNode buildExpectedFilterNode() throws Exception { - return mapper.readTree(QUERY_FILTER); - } + @Mock + private EsQueryProcessor queryPreprocessor; - private void invokeAddFilter(EsHTTPProxy proxy, ObjectNode root) throws Exception { - getAddFilterToQueryMethod().invoke(proxy, null, mapper, root); - } + @Mock + private HttpServletRequest request; - private void assertAppendedFilter(ArrayNode filters, JsonNode expectedFilter) { - Assert.assertEquals("Expected original filter + injected access filter", 2, filters.size()); - Assert.assertEquals("Injected filter should be appended as second filter", expectedFilter, filters.get(1)); - } + @Mock + private HttpServletResponse response; - @Test - public void shouldCreateBoolQueryWithMatchAllAndAccessFilterWhenQueryIsMissing() throws Exception { - EsHTTPProxy proxy = new TestableEsHTTPProxy(); - JsonNode expectedFilter = buildExpectedFilterNode(); + @Mock + private HttpSession session; - ObjectNode root = mapper.createObjectNode(); + @Mock + private ServiceContext serviceContext; - invokeAddFilter(proxy, root); + @Mock + private HttpURLConnection mockConnection; + + private TestableEsHTTPProxy esHTTPProxy; - JsonNode query = root.get("query"); - Assert.assertNotNull("A query node should be created", query); - JsonNode bool = query.get("bool"); - Assert.assertNotNull(bool); - Assert.assertTrue("The bool query should contain a must clause", bool.has("must")); - Assert.assertTrue("The bool query should contain a filter clause", bool.has("filter")); + private static final String DEFAULT_INDEX = "gn-records"; + private static final String SERVER_URL = "http://localhost:9200"; - JsonNode must = bool.get("must"); - Assert.assertTrue("Missing query should be replaced by match_all", must.has("match_all")); - Assert.assertEquals("Expected access filter was not injected", expectedFilter, bool.get("filter")); + @Before + public void setUp() { + esHTTPProxy = new TestableEsHTTPProxy(mockConnection); + ReflectionTestUtils.setField(esHTTPProxy, "defaultIndex", DEFAULT_INDEX); + ReflectionTestUtils.setField(esHTTPProxy, "proxyHeadersAllowedList", new String[]{"content-type"}); + ReflectionTestUtils.setField(esHTTPProxy, "client", esRestClient); + ReflectionTestUtils.setField(esHTTPProxy, "responseProcessor", responseProcessor); + ReflectionTestUtils.setField(esHTTPProxy, "queryPreprocessor", queryPreprocessor); + + lenient().when(esRestClient.getServerUrl()).thenReturn(SERVER_URL); } @Test - public void shouldConvertSingleBoolFilterToArrayAndAppendAccessFilter() throws Exception { - EsHTTPProxy proxy = new TestableEsHTTPProxy(); - JsonNode expectedFilter = buildExpectedFilterNode(); - - ObjectNode filterObj = mapper.createObjectNode(); - filterObj.putObject("term").put("a", "b"); - - ObjectNode boolNode = mapper.createObjectNode(); - boolNode.set("must", mapper.createObjectNode().putObject("match").put("field", "value")); - boolNode.set("filter", filterObj); - - ObjectNode root = mapper.createObjectNode(); - root.set("query", mapper.createObjectNode().set("bool", boolNode)); - - invokeAddFilter(proxy, root); - - JsonNode resultingFilter = root.get("query").get("bool").get("filter"); - Assert.assertTrue("Existing object filter should be converted to array", resultingFilter.isArray()); - ArrayNode arr = (ArrayNode) resultingFilter; - Assert.assertEquals("Original filter should stay first", filterObj, arr.get(0)); - assertAppendedFilter(arr, expectedFilter); + public void testSearchSuccessful() throws Exception { + String body = "{\"query\":{\"match_all\":{}}}"; + String processedBody = "{\"query\":{\"match_all\":{}}, \"filter\":{}}"; + + try (MockedStatic apiUtils = mockStatic(ApiUtils.class)) { + apiUtils.when(() -> ApiUtils.createServiceContext(any())).thenReturn(serviceContext); + when(queryPreprocessor.process(eq(serviceContext), eq(body), anyString())).thenReturn(processedBody); + when(request.getMethod()).thenReturn("POST"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + + // Mock URL connection + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + when(mockConnection.getOutputStream()).thenReturn(outputStream); + when(mockConnection.getResponseCode()).thenReturn(200); + when(mockConnection.getContentType()).thenReturn("application/json"); + when(mockConnection.getInputStream()).thenReturn(new ByteArrayInputStream("{}".getBytes())); + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + when(mockConnection.getHeaderFields()).thenReturn(headers); + + // Mock ServletOutputStream + final ByteArrayOutputStream responseOut = new ByteArrayOutputStream(); + ServletOutputStream servletOutputStream = new ServletOutputStream() { + @Override + public boolean isReady() { return true; } + @Override + public void setWriteListener(WriteListener writeListener) {} + @Override + public void write(int b) throws IOException { responseOut.write(b); } + }; + when(response.getOutputStream()).thenReturn(servletOutputStream); + + esHTTPProxy.search("bucket", new RelatedItemType[0], session, request, response, body); + + // Verify + verify(queryPreprocessor).process(eq(serviceContext), eq(body), eq("bucket")); + verify(mockConnection).connect(); + verify(responseProcessor).processResponse(eq(serviceContext), eq(session), any(), any(), eq("_search"), eq("bucket"), eq(true), any()); + assertEquals(processedBody, outputStream.toString("UTF-8")); + } } @Test - public void shouldAppendAccessFilterToExistingBoolFilterArray() throws Exception { - EsHTTPProxy proxy = new TestableEsHTTPProxy(); - JsonNode expectedFilter = buildExpectedFilterNode(); + public void testSearchError() throws Exception { + String body = "{\"query\":{\"match_all\":{}}}"; + String processedBody = body; - ObjectNode existingFilter = mapper.createObjectNode(); - existingFilter.putObject("term").put("c", "d"); + try (MockedStatic apiUtils = mockStatic(ApiUtils.class)) { + apiUtils.when(() -> ApiUtils.createServiceContext(any())).thenReturn(serviceContext); + when(queryPreprocessor.process(eq(serviceContext), eq(body), anyString())).thenReturn(processedBody); + when(request.getMethod()).thenReturn("POST"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); - ArrayNode filterArray = mapper.createArrayNode(); - filterArray.add(existingFilter); + when(mockConnection.getResponseCode()).thenReturn(400); + when(mockConnection.getResponseMessage()).thenReturn("Bad Request"); + when(mockConnection.getErrorStream()).thenReturn(new ByteArrayInputStream("error details".getBytes())); + when(mockConnection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); - ObjectNode boolNode = mapper.createObjectNode(); - boolNode.set("filter", filterArray); + esHTTPProxy.search("bucket", new RelatedItemType[0], session, request, response, body); - ObjectNode root = mapper.createObjectNode(); - root.set("query", mapper.createObjectNode().set("bool", boolNode)); - - invokeAddFilter(proxy, root); - - JsonNode resultingFilter = root.get("query").get("bool").get("filter"); - Assert.assertTrue("Filter should remain an array", resultingFilter.isArray()); - ArrayNode arr = (ArrayNode) resultingFilter; - Assert.assertEquals(existingFilter, arr.get(0)); - assertAppendedFilter(arr, expectedFilter); + verify(response).sendError(eq(400), contains("Bad Request")); + verify(response).sendError(eq(400), contains("error details")); + } } @Test - public void shouldWrapFunctionScoreInnerQueryIntoBoolAndInjectAccessFilter() throws Exception { - EsHTTPProxy proxy = new TestableEsHTTPProxy(); - JsonNode expectedFilter = buildExpectedFilterNode(); - - ObjectNode innerQuery = mapper.createObjectNode(); - innerQuery.putObject("match").put("title", "abc"); - - ObjectNode functionScore = mapper.createObjectNode(); - functionScore.set("query", innerQuery); - functionScore.putArray("functions"); // keep valid shape - - ObjectNode root = mapper.createObjectNode(); - root.set("query", mapper.createObjectNode().set("function_score", functionScore)); - - invokeAddFilter(proxy, root); + public void testMSearchSuccessful() throws Exception { + String body = "{}\n{\"query\":{\"match_all\":{}}}"; + String processedBody = body; + + try (MockedStatic apiUtils = mockStatic(ApiUtils.class)) { + apiUtils.when(() -> ApiUtils.createServiceContext(any())).thenReturn(serviceContext); + when(queryPreprocessor.process(eq(serviceContext), eq(body), anyString())).thenReturn(processedBody); + when(request.getMethod()).thenReturn("POST"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + + // Mock URL connection + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + when(mockConnection.getOutputStream()).thenReturn(outputStream); + when(mockConnection.getResponseCode()).thenReturn(200); + when(mockConnection.getContentType()).thenReturn("application/x-ndjson"); + when(mockConnection.getInputStream()).thenReturn(new ByteArrayInputStream("{}".getBytes())); + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/x-ndjson")); + when(mockConnection.getHeaderFields()).thenReturn(headers); + + // Mock ServletOutputStream + final ByteArrayOutputStream responseOut = new ByteArrayOutputStream(); + ServletOutputStream servletOutputStream = new ServletOutputStream() { + @Override + public boolean isReady() { return true; } + @Override + public void setWriteListener(WriteListener writeListener) {} + @Override + public void write(int b) throws IOException { responseOut.write(b); } + }; + when(response.getOutputStream()).thenReturn(servletOutputStream); + + esHTTPProxy.msearch("bucket", new RelatedItemType[0], session, request, response, body); + + // Verify + verify(queryPreprocessor).process(eq(serviceContext), eq(body), eq("bucket")); + verify(mockConnection).connect(); + // addPermissions should be false because contentType is not application/json + verify(responseProcessor).processResponse(eq(serviceContext), eq(session), any(), any(), eq("_msearch"), eq("bucket"), eq(false), any()); + } + } - JsonNode newFunctionQuery = root.get("query").get("function_score").get("query"); - Assert.assertTrue("function_score.query should become a bool query", newFunctionQuery.has("bool")); - JsonNode bool = newFunctionQuery.get("bool"); - Assert.assertTrue(bool.has("must")); - Assert.assertTrue(bool.has("filter")); - Assert.assertEquals(innerQuery, bool.get("must")); - Assert.assertEquals(expectedFilter, bool.get("filter")); + @Test + public void testCallAdmin() throws Exception { + String body = "{\"query\":{\"match_all\":{}}}"; + String endPoint = "_count"; + + try (MockedStatic apiUtils = mockStatic(ApiUtils.class)) { + apiUtils.when(() -> ApiUtils.createServiceContext(any())).thenReturn(serviceContext); + when(request.getMethod()).thenReturn("POST"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.emptyList())); + + // Mock URL connection + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + when(mockConnection.getOutputStream()).thenReturn(outputStream); + when(mockConnection.getResponseCode()).thenReturn(200); + when(mockConnection.getContentType()).thenReturn("application/json"); + when(mockConnection.getInputStream()).thenReturn(new ByteArrayInputStream("{}".getBytes())); + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + when(mockConnection.getHeaderFields()).thenReturn(headers); + + // Mock ServletOutputStream + final ByteArrayOutputStream responseOut = new ByteArrayOutputStream(); + ServletOutputStream servletOutputStream = new ServletOutputStream() { + @Override + public boolean isReady() { return true; } + @Override + public void setWriteListener(WriteListener writeListener) {} + @Override + public void write(int b) throws IOException { responseOut.write(b); } + }; + when(response.getOutputStream()).thenReturn(servletOutputStream); + + esHTTPProxy.call("bucket", endPoint, session, request, response, body); + + // Verify + // queryPreprocessor.process should NOT be called for other endpoints + verify(queryPreprocessor, never()).process(any(), any(), any()); + verify(mockConnection).connect(); + verify(responseProcessor).processResponse(eq(serviceContext), eq(session), any(), any(), eq(endPoint), eq("bucket"), eq(true), isNull()); + assertEquals(body, outputStream.toString("UTF-8")); + } } @Test - public void shouldAppendAccessFilterWhenFunctionScoreInnerBoolAlreadyHasFilter() throws Exception { - EsHTTPProxy proxy = new TestableEsHTTPProxy(); - JsonNode expectedFilter = buildExpectedFilterNode(); + public void testHeaderCopyingAndAuth() throws Exception { + String body = "{}"; + ReflectionTestUtils.setField(esHTTPProxy, "username", "user"); + ReflectionTestUtils.setField(esHTTPProxy, "password", "pass"); + + try (MockedStatic apiUtils = mockStatic(ApiUtils.class)) { + apiUtils.when(() -> ApiUtils.createServiceContext(any())).thenReturn(serviceContext); + when(request.getMethod()).thenReturn("POST"); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(Collections.singletonList("Custom-Header"))); + when(request.getHeader("Custom-Header")).thenReturn("Custom-Value"); - ObjectNode innerBool = mapper.createObjectNode(); - innerBool.set("must", mapper.createObjectNode().putObject("match").put("f", "v")); - innerBool.set("filter", mapper.createObjectNode().putObject("term").put("x", "y")); + lenient().when(mockConnection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + lenient().when(mockConnection.getResponseCode()).thenReturn(200); + lenient().when(mockConnection.getContentType()).thenReturn("application/json"); + lenient().when(mockConnection.getInputStream()).thenReturn(new ByteArrayInputStream("{}".getBytes())); - ObjectNode functionScore = mapper.createObjectNode(); - functionScore.set("query", mapper.createObjectNode().set("bool", innerBool)); + Map> headers = new HashMap<>(); + headers.put("content-type", Collections.singletonList("application/json")); + lenient().when(mockConnection.getHeaderFields()).thenReturn(headers); - ObjectNode root = mapper.createObjectNode(); - root.set("query", mapper.createObjectNode().set("function_score", functionScore)); + lenient().when(response.getOutputStream()).thenReturn(mock(ServletOutputStream.class)); - invokeAddFilter(proxy, root); + esHTTPProxy.search("bucket", new RelatedItemType[0], session, request, response, body); - JsonNode resultingFilter = root.get("query").get("function_score").get("query").get("bool").get("filter"); - Assert.assertTrue("Inner bool filter should be converted to array", resultingFilter.isArray()); - ArrayNode arr = (ArrayNode) resultingFilter; - assertAppendedFilter(arr, expectedFilter); + // Verify request headers + verify(mockConnection).setRequestProperty("Custom-Header", "Custom-Value"); + verify(mockConnection).setRequestProperty(eq("Authorization"), startsWith("Basic ")); + } } } diff --git a/services/src/test/java/org/fao/geonet/api/es/EsResponseContentTypeValidatorTest.java b/services/src/test/java/org/fao/geonet/api/es/EsResponseContentTypeValidatorTest.java new file mode 100644 index 000000000000..ac677435c9d6 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/EsResponseContentTypeValidatorTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.HttpURLConnection; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.*; + +public class EsResponseContentTypeValidatorTest { + + @Mock + private HttpURLConnection connection; + + @Mock + private HttpServletResponse response; + + private EsResponseContentTypeValidator validator; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + validator = new EsResponseContentTypeValidator(); + } + + @Test + public void validateContentType_ShouldReturnError_WhenContentTypeIsNull() throws IOException { + validator.validateContentType(connection, response, null); + + verify(response).sendError(eq(HttpServletResponse.SC_FORBIDDEN), contains("content type given by remote host is null")); + } + + @Test + public void validateContentType_ShouldDoNothing_WhenContentTypeIsValid() throws IOException { + validator.validateContentType(connection, response, "application/json"); + validator.validateContentType(connection, response, "text/plain"); + + verifyNoInteractions(response); + } + + @Test + public void validateContentType_ShouldReturnNotFound_WhenContentTypeIsInvalidAndResponseMessageIsNotFound() throws IOException { + when(connection.getResponseMessage()).thenReturn("Not Found"); + + validator.validateContentType(connection, response, "text/html"); + + verify(response).sendError(eq(HttpServletResponse.SC_NOT_FOUND), eq("Remote host not found")); + } + + @Test + public void validateContentType_ShouldReturnForbidden_WhenContentTypeIsInvalidAndResponseMessageIsNotNotFound() throws IOException { + when(connection.getResponseMessage()).thenReturn("Internal Server Error"); + + validator.validateContentType(connection, response, "text/html"); + + verify(response).sendError(eq(HttpServletResponse.SC_FORBIDDEN), contains("is not allowed by the proxy rules")); + } + + @Test + public void validateContentType_ShouldReturnForbidden_WhenContentTypeIsInvalidAndResponseMessageIsNull() throws IOException { + when(connection.getResponseMessage()).thenReturn(null); + + validator.validateContentType(connection, response, "text/html"); + + verify(response).sendError(eq(HttpServletResponse.SC_FORBIDDEN), contains("is not allowed by the proxy rules")); + } + + @Test + public void isContentTypeValid_ShouldReturnTrue_ForValidContentTypes() { + assertTrue(validator.isContentTypeValid("application/json")); + assertTrue(validator.isContentTypeValid("APPLICATION/JSON")); + assertTrue(validator.isContentTypeValid("text/plain")); + assertTrue(validator.isContentTypeValid("TEXT/PLAIN")); + } + + @Test + public void isContentTypeValid_ShouldReturnTrue_ForValidContentTypesWithEncoding() { + assertTrue(validator.isContentTypeValid("application/json;charset=UTF-8")); + assertTrue(validator.isContentTypeValid("application/json; charset=UTF-8")); + assertTrue(validator.isContentTypeValid("text/plain; charset=iso-8859-1")); + assertTrue(validator.isContentTypeValid("text/plain ; charset=iso-8859-1")); + } + + @Test + public void isContentTypeValid_ShouldReturnFalse_ForInvalidContentTypes() { + assertFalse(validator.isContentTypeValid("text/html")); + assertFalse(validator.isContentTypeValid("application/xml")); + assertFalse(validator.isContentTypeValid("")); + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/ObjectNodeUtilsTest.java b/services/src/test/java/org/fao/geonet/api/es/ObjectNodeUtilsTest.java new file mode 100644 index 000000000000..d9fb7628bdaf --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/ObjectNodeUtilsTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ObjectNodeUtilsTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void getSourceString_ShouldReturnString_WhenFieldExists() { + ObjectNode source = mapper.createObjectNode(); + source.put("testField", "testValue"); + ObjectNode node = mapper.createObjectNode(); + node.set(ObjectNodeUtils.SOURCE_NODE, source); + + assertEquals("testValue", ObjectNodeUtils.getSourceString(node, "testField")); + } + + @Test + public void getSourceString_ShouldReturnNull_WhenFieldDoesNotExist() { + ObjectNode source = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + node.set(ObjectNodeUtils.SOURCE_NODE, source); + + assertNull(ObjectNodeUtils.getSourceString(node, "missingField")); + } + + @Test + public void getSourceString_ShouldReturnNull_WhenSourceNodeMissing() { + ObjectNode node = mapper.createObjectNode(); + assertNull(ObjectNodeUtils.getSourceString(node, "testField")); + } + + @Test + public void getSourceInteger_ShouldReturnInteger_WhenFieldExists() { + ObjectNode source = mapper.createObjectNode(); + source.put("testField", 123); + ObjectNode node = mapper.createObjectNode(); + node.set(ObjectNodeUtils.SOURCE_NODE, source); + + assertEquals(Integer.valueOf(123), ObjectNodeUtils.getSourceInteger(node, "testField")); + } + + @Test + public void getSourceInteger_ShouldReturnNull_WhenFieldDoesNotExist() { + ObjectNode source = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + node.set(ObjectNodeUtils.SOURCE_NODE, source); + + assertNull(ObjectNodeUtils.getSourceInteger(node, "missingField")); + } + + @Test + public void getSourceInteger_ShouldReturnNull_WhenSourceNodeMissing() { + ObjectNode node = mapper.createObjectNode(); + assertNull(ObjectNodeUtils.getSourceInteger(node, "testField")); + } + + @Test + public void getSourceNode_ShouldReturnNode_WhenSourceNodeExists() { + ObjectNode source = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + node.set(ObjectNodeUtils.SOURCE_NODE, source); + + ObjectNode result = ObjectNodeUtils.getSourceNode(node); + assertNotNull(result); + assertEquals(source, result); + } + + @Test + public void getSourceNode_ShouldReturnNull_WhenSourceNodeMissing() { + ObjectNode node = mapper.createObjectNode(); + assertNull(ObjectNodeUtils.getSourceNode(node)); + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilderTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilderTest.java new file mode 100644 index 000000000000..ce89c630a22e --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryFilterBuilderTest.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.query; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.NodeInfo; +import org.fao.geonet.kernel.search.EsFilterBuilder; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class EsQueryFilterBuilderTest { + + @Mock + private ServiceContext context; + + @Mock + private NodeInfo node; + + @InjectMocks + private EsQueryFilterBuilder esQueryFilterBuilder; + + private ObjectMapper objectMapper = new ObjectMapper(); + private MockedStatic esFilterBuilderMockedStatic; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + esFilterBuilderMockedStatic = mockStatic(EsFilterBuilder.class); + // Default mock for EsFilterBuilder.build + esFilterBuilderMockedStatic.when(() -> EsFilterBuilder.build(any(), anyString(), anyBoolean(), any())) + .thenReturn("group:1"); + } + + @After + public void tearDown() { + esFilterBuilderMockedStatic.close(); + } + + @Test + public void testAddFilterToQuery_NoQuery() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + assertQueryWrappedWithFilter(esQuery); + } + + @Test + public void testAddFilterToQuery_NullQuery() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putNull("query"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + assertQueryWrappedWithFilter(esQuery); + } + + @Test + public void testAddFilterToQuery_EmptyObjectQuery() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putObject("query"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + assertQueryWrappedWithFilter(esQuery); + } + + private void assertQueryWrappedWithFilter(ObjectNode esQuery) { + assertTrue(esQuery.has("query")); + assertTrue(esQuery.get("query").has("bool")); + JsonNode bool = esQuery.get("query").get("bool"); + assertTrue(bool.has("must")); + assertTrue(bool.get("must").has("match_all")); + assertTrue(bool.has("filter")); + assertEquals("group:1", bool.get("filter").get("query_string").get("query").asText()); + } + + @Test + public void testAddFilterToQuery_TopLevelBool() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + ObjectNode boolNode = esQuery.putObject("query").putObject("bool"); + boolNode.putObject("must").putObject("match_all"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode bool = esQuery.get("query").get("bool"); + assertTrue(bool.has("filter")); + assertEquals("group:1", bool.get("filter").get("query_string").get("query").asText()); + } + + @Test + public void testAddFilterToQuery_FunctionScore_NoInnerQuery() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putObject("query").putObject("function_score"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode functionScore = esQuery.get("query").get("function_score"); + assertTrue(functionScore.has("query")); + JsonNode innerBool = functionScore.get("query").get("bool"); + assertNotNull(innerBool); + assertTrue(innerBool.has("filter")); + assertEquals("group:1", innerBool.get("filter").get("query_string").get("query").asText()); + } + + @Test + public void testAddFilterToQuery_FunctionScore_InnerBool() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + ObjectNode functionScore = esQuery.putObject("query").putObject("function_score"); + functionScore.putObject("query").putObject("bool").putObject("must").putObject("match_all"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode innerBool = esQuery.get("query").get("function_score").get("query").get("bool"); + assertTrue(innerBool.has("filter")); + assertEquals("group:1", innerBool.get("filter").get("query_string").get("query").asText()); + } + + @Test + public void testAddFilterToQuery_FunctionScore_InnerOther() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + ObjectNode functionScore = esQuery.putObject("query").putObject("function_score"); + functionScore.putObject("query").putObject("match_all"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode innerQuery = esQuery.get("query").get("function_score").get("query"); + assertTrue(innerQuery.has("bool")); + JsonNode innerBool = innerQuery.get("bool"); + assertTrue(innerBool.has("must")); + assertTrue(innerBool.get("must").has("match_all")); + assertTrue(innerBool.has("filter")); + } + + @Test + public void testAddFilterToQuery_OtherQueryType() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putObject("query").putObject("match_all"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode queryNode = esQuery.get("query"); + assertTrue(queryNode.has("bool")); + JsonNode bool = queryNode.get("bool"); + assertTrue(bool.has("must")); + assertTrue(bool.get("must").has("match_all")); + assertTrue(bool.has("filter")); + } + + @Test + public void testAddFilterToQuery_ExistingFilterArray() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + ObjectNode boolNode = esQuery.putObject("query").putObject("bool"); + boolNode.putArray("filter").addObject().putObject("term").put("field", "value"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode filter = esQuery.get("query").get("bool").get("filter"); + assertTrue(filter.isArray()); + assertEquals(2, filter.size()); + assertEquals("group:1", filter.get(1).get("query_string").get("query").asText()); + } + + @Test + public void testAddFilterToQuery_ExistingFilterObject() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + ObjectNode boolNode = esQuery.putObject("query").putObject("bool"); + boolNode.putObject("filter").putObject("term").put("field", "value"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode filter = esQuery.get("query").get("bool").get("filter"); + assertTrue(filter.isArray()); + assertEquals(2, filter.size()); + assertEquals("group:1", filter.get(1).get("query_string").get("query").asText()); + } + + @Test + public void testAddFilterToQuery_DraftDetection() throws Exception { + // Use a more realistic query structure + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putObject("query").putObject("term").put("draft", true); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + esFilterBuilderMockedStatic.verify(() -> EsFilterBuilder.build(any(), anyString(), eq(true), any())); + + ObjectNode esQuery2 = objectMapper.createObjectNode(); + esQuery2.putObject("query").putObject("match_all"); + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery2); + + esFilterBuilderMockedStatic.verify(() -> EsFilterBuilder.build(any(), anyString(), eq(false), any())); + } + + @Test + public void testAddFilterToQuery_GlobalAggregations() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putObject("query").putObject("match_all"); + ObjectNode aggs = esQuery.putObject("aggs"); + ObjectNode globalAgg = aggs.putObject("all_docs"); + globalAgg.putObject("global"); + globalAgg.putObject("aggs").putObject("avg_size").putObject("avg").put("field", "size"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode allDocs = esQuery.get("aggs").get("all_docs"); + assertFalse("global should be removed", allDocs.has("global")); + assertTrue("filter should be added", allDocs.has("filter")); + assertEquals("group:1", allDocs.get("filter").get("query_string").get("query").asText()); + assertTrue("sub-aggs should be preserved", allDocs.has("aggs")); + assertTrue(allDocs.get("aggs").has("avg_size")); + } + + @Test + public void testAddFilterToQuery_NestedGlobalAggregations() throws Exception { + ObjectNode esQuery = objectMapper.createObjectNode(); + esQuery.putObject("query").putObject("match_all"); + ObjectNode aggregations = esQuery.putObject("aggregations"); + ObjectNode topAgg = aggregations.putObject("by_type"); + topAgg.putObject("terms").put("field", "type"); + ObjectNode subAggs = topAgg.putObject("aggregations"); + ObjectNode nestedGlobal = subAggs.putObject("global_data"); + nestedGlobal.putObject("global"); + + esQueryFilterBuilder.addFilterToQuery(context, objectMapper, esQuery); + + JsonNode globalData = esQuery.get("aggregations").get("by_type").get("aggregations").get("global_data"); + assertFalse(globalData.has("global")); + assertTrue(globalData.has("filter")); + assertEquals("group:1", globalData.get("filter").get("query_string").get("query").asText()); + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryProcessorTest.java new file mode 100644 index 000000000000..06f0063d9828 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/query/EsQueryProcessorTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.query; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.UserSession; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Geonet; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +public class EsQueryProcessorTest { + + @InjectMocks + private EsQueryProcessor esQueryProcessor; + + @Mock + private EsQueryFilterBuilder queryFilterBuilder; + + @Mock + private ServiceContext context; + + @Mock + private UserSession userSession; + + private ObjectMapper objectMapper = new ObjectMapper(); + private String defaultIndex = "gn-records-default"; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(esQueryProcessor, "defaultIndex", defaultIndex); + when(context.getUserSession()).thenReturn(userSession); + } + + @Test + public void testProcessSingleQuery() throws Exception { + String body = "{\"query\": {\"match_all\": {}}}"; + String result = esQueryProcessor.process(context, body, null); + + verify(queryFilterBuilder).addFilterToQuery(eq(context), any(ObjectMapper.class), any(JsonNode.class)); + JsonNode resultNode = objectMapper.readTree(result); + assertTrue(resultNode.has("query")); + } + + @Test + public void testProcessQueryWithIndexNode() throws Exception { + String body = "{\"index\": \"some-index\"}"; + String result = esQueryProcessor.process(context, body, null); + + // Should NOT call filter builder if index node is present + verify(queryFilterBuilder, never()).addFilterToQuery(any(), any(), any()); + + JsonNode resultNode = objectMapper.readTree(result); + assertEquals(defaultIndex, resultNode.get("index").asText()); + } + + @Test + public void testProcessMultiSearch() throws Exception { + String body = "{\"index\": \"index1\"}\n{\"query\": {\"match_all\": {}}}"; + String result = esQueryProcessor.process(context, body, null); + + String[] lines = result.split(System.lineSeparator()); + assertEquals(2, lines.length); + + JsonNode line1 = objectMapper.readTree(lines[0]); + assertEquals(defaultIndex, line1.get("index").asText()); + + JsonNode line2 = objectMapper.readTree(lines[1]); + assertTrue(line2.has("query")); + verify(queryFilterBuilder, times(1)).addFilterToQuery(eq(context), any(ObjectMapper.class), any(JsonNode.class)); + } + + @Test + public void testProcessWithSelectionBucket() throws Exception { + String body = "{\"query\": {\"match_all\": {}}}"; + String bucket = "myBucket"; + esQueryProcessor.process(context, body, bucket); + + verify(userSession).setProperty(eq(Geonet.Session.SEARCH_REQUEST + bucket), any(JsonNode.class)); + } + + @Test + public void testProcessWithSourceArray() throws Exception { + String body = "{\"_source\": [\"title\", \"abstract\"]}"; + String result = esQueryProcessor.process(context, body, null); + + JsonNode resultNode = objectMapper.readTree(result); + ArrayNode source = (ArrayNode) resultNode.get(ObjectNodeUtils.SOURCE_NODE); + + assertRequiredFields(source); + } + + @Test + public void testProcessWithSourceIncludes() throws Exception { + String body = "{\"_source\": {\"includes\": [\"title\"], \"excludes\": [\"extra\"]}}"; + String result = esQueryProcessor.process(context, body, null); + + JsonNode resultNode = objectMapper.readTree(result); + ArrayNode includes = (ArrayNode) resultNode.get(ObjectNodeUtils.SOURCE_NODE).get("includes"); + + assertRequiredFields(includes); + } + + @Test + public void testProcessWithSourceNoIncludes() throws Exception { + String body = "{\"_source\": {\"excludes\": [\"extra\"]}}"; + String result = esQueryProcessor.process(context, body, null); + + JsonNode resultNode = objectMapper.readTree(result); + ObjectNode source = (ObjectNode) resultNode.get(ObjectNodeUtils.SOURCE_NODE); + + assertEquals(1, source.size()); + assertTrue(source.has("excludes")); + } + + private void assertRequiredFields(ArrayNode array) { + assertTrue(contains(array, "op*")); + assertTrue(contains(array, Geonet.IndexFieldNames.SCHEMA)); + assertTrue(contains(array, Geonet.IndexFieldNames.GROUP_OWNER)); + assertTrue(contains(array, Geonet.IndexFieldNames.OWNER)); + assertTrue(contains(array, Geonet.IndexFieldNames.ID)); + } + + private boolean contains(ArrayNode array, String value) { + for (JsonNode node : array) { + if (node.asText().equals(value)) { + return true; + } + } + return false; + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessorTest.java new file mode 100644 index 000000000000..973ba4dcc7dd --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentMetadataFiltersProcessorTest.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.UserSession; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.Profile; +import org.fao.geonet.domain.ReservedOperation; +import org.fao.geonet.kernel.AccessManager; +import org.fao.geonet.kernel.SchemaManager; +import org.fao.geonet.kernel.schema.MetadataOperationFilterType; +import org.fao.geonet.kernel.schema.MetadataSchema; +import org.fao.geonet.kernel.schema.MetadataSchemaOperationFilter; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class EsDocumentMetadataFiltersProcessorTest { + + @InjectMocks + private EsDocumentMetadataFiltersProcessor processor; + + @Mock + private SchemaManager schemaManager; + + @Mock + private ServiceContext context; + + @Mock + private UserSession userSession; + + @Mock + private AccessManager accessManager; + + @Mock + private MetadataSchema metadataSchema; + + private ObjectMapper mapper = new ObjectMapper(); + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(context.getUserSession()).thenReturn(userSession); + when(context.getBean(AccessManager.class)).thenReturn(accessManager); + } + + @Test + public void processMetadataSchemaFilters_ShouldReturn_WhenSourceNodeIsMissing() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + processor.process(doc, context, Collections.emptyMap()); + assertFalse(doc.has(ObjectNodeUtils.SOURCE_NODE)); + } + + @Test + public void processMetadataSchemaFilters_ShouldReturn_WhenSchemaIsInvalid() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "invalid-schema"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(schemaManager.getSchema("invalid-schema")).thenThrow(new IllegalArgumentException("Invalid schema")); + + processor.process(doc, context, Collections.emptyMap()); + + // Source node should remain unchanged + assertEquals("invalid-schema", doc.get(ObjectNodeUtils.SOURCE_NODE).get(Geonet.IndexFieldNames.SCHEMA).asText()); + } + + @Test + public void processMetadataSchemaFilters_ShouldApplyAuthenticatedFilter_WhenNotAuthenticated() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + source.put("secretField", "secretValue"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(false); + + MetadataSchemaOperationFilter filter = mock(MetadataSchemaOperationFilter.class); + when(filter.getJsonpath()).thenReturn("$.secretField"); + when(metadataSchema.getOperationFilter(MetadataOperationFilterType.authenticated.name())).thenReturn(filter); + + processor.process(doc, context, Collections.emptyMap()); + + assertFalse(doc.get(ObjectNodeUtils.SOURCE_NODE).has("secretField")); + } + + @Test + public void processMetadataSchemaFilters_ShouldNotApplyAuthenticatedFilter_WhenAuthenticated() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + source.put("secretField", "secretValue"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(true); + when(userSession.getProfile()).thenReturn(Profile.RegisteredUser); + + MetadataSchemaOperationFilter filter = mock(MetadataSchemaOperationFilter.class); + when(filter.getJsonpath()).thenReturn("$.secretField"); + when(metadataSchema.getOperationFilter(MetadataOperationFilterType.authenticated.name())).thenReturn(filter); + + processor.process(doc, context, Collections.emptyMap()); + + assertTrue(doc.get(ObjectNodeUtils.SOURCE_NODE).has("secretField")); + } + + @Test + public void processMetadataSchemaFilters_ShouldApplyEditFilter_WhenCannotEdit() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + source.put("editOnlyField", "value"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + doc.put("edit", false); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(true); + when(userSession.getProfile()).thenReturn(Profile.Administrator); + + MetadataSchemaOperationFilter filter = mock(MetadataSchemaOperationFilter.class); + when(filter.getJsonpath()).thenReturn("$.editOnlyField"); + when(metadataSchema.getOperationFilter(ReservedOperation.editing)).thenReturn(filter); + + processor.process(doc, context, Collections.emptyMap()); + + assertFalse(doc.get(ObjectNodeUtils.SOURCE_NODE).has("editOnlyField")); + } + + @Test + public void processMetadataSchemaFilters_ShouldHandlePathNotFoundExceptionGracefully() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + doc.put("edit", false); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(true); + when(userSession.getProfile()).thenReturn(Profile.Administrator); + + MetadataSchemaOperationFilter filter = mock(MetadataSchemaOperationFilter.class); + when(filter.getJsonpath()).thenReturn("$.nonExistentField"); + when(metadataSchema.getOperationFilter(ReservedOperation.editing)).thenReturn(filter); + + processor.process(doc, context, Collections.emptyMap()); + + // Should not throw exception and source should be same as before (modulo formatting) + assertTrue(doc.has(ObjectNodeUtils.SOURCE_NODE)); + } + + @Test + public void processMetadataSchemaFilters_ShouldApplyGroupOwnerFilter_WhenNotGroupOwner() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + source.put(Geonet.IndexFieldNames.GROUP_OWNER, 10); + source.put("ownerOnlyField", "value"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(true); + when(userSession.getProfile()).thenReturn(Profile.Editor); + + MetadataSchemaOperationFilter filter = mock(MetadataSchemaOperationFilter.class); + when(filter.getJsonpath()).thenReturn("$.ownerOnlyField"); + when(metadataSchema.getOperationFilter(MetadataOperationFilterType.groupOwner.name())).thenReturn(filter); + + processor.process(doc, context, Collections.emptyMap()); + + assertFalse(doc.get(ObjectNodeUtils.SOURCE_NODE).has("ownerOnlyField")); + } + + @Test + public void processMetadataSchemaFilters_ShouldNotApplyGroupOwnerFilter_WhenIsAdministrator() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + source.put(Geonet.IndexFieldNames.GROUP_OWNER, 10); + source.put("ownerOnlyField", "value"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(true); + when(userSession.getProfile()).thenReturn(Profile.Administrator); + + MetadataSchemaOperationFilter filter = mock(MetadataSchemaOperationFilter.class); + when(filter.getJsonpath()).thenReturn("$.ownerOnlyField"); + when(metadataSchema.getOperationFilter(MetadataOperationFilterType.groupOwner.name())).thenReturn(filter); + + processor.process(doc, context, Collections.emptyMap()); + + assertTrue(doc.get(ObjectNodeUtils.SOURCE_NODE).has("ownerOnlyField")); + } + + @Test + public void processMetadataSchemaFilters_ShouldApplyMultipleFilters() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.SCHEMA, "iso19139"); + source.put("field1", "value1"); + source.put("field2", "value2"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + doc.put("edit", false); + doc.put("download", false); + + when(schemaManager.getSchema("iso19139")).thenReturn(metadataSchema); + when(userSession.isAuthenticated()).thenReturn(true); + when(userSession.getProfile()).thenReturn(Profile.Administrator); + + MetadataSchemaOperationFilter editFilter = mock(MetadataSchemaOperationFilter.class); + when(editFilter.getJsonpath()).thenReturn("$.field1"); + when(metadataSchema.getOperationFilter(ReservedOperation.editing)).thenReturn(editFilter); + + MetadataSchemaOperationFilter downloadFilter = mock(MetadataSchemaOperationFilter.class); + when(downloadFilter.getJsonpath()).thenReturn("$.field2"); + when(metadataSchema.getOperationFilter(ReservedOperation.download)).thenReturn(downloadFilter); + + processor.process(doc, context, Collections.emptyMap()); + + assertFalse(doc.get(ObjectNodeUtils.SOURCE_NODE).has("field1")); + assertFalse(doc.get(ObjectNodeUtils.SOURCE_NODE).has("field2")); + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessorTest.java new file mode 100644 index 000000000000..c70b149aee0f --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRelatedTypesProcessorTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.api.records.MetadataUtils; +import org.fao.geonet.api.records.model.related.AssociatedRecord; +import org.fao.geonet.api.records.model.related.RelatedItemType; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.AbstractMetadata; +import org.fao.geonet.kernel.datamanager.IMetadataUtils; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class EsDocumentRelatedTypesProcessorTest { + + private EsDocumentRelatedTypesProcessor processor; + private ObjectMapper mapper; + + @Mock + private ServiceContext context; + + @Mock + private IMetadataUtils metadataUtils; + + @Mock + private AbstractMetadata metadata; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + processor = new EsDocumentRelatedTypesProcessor(); + mapper = new ObjectMapper(); + when(context.getBean(IMetadataUtils.class)).thenReturn(metadataUtils); + } + + @Test + public void process_ShouldAddRelatedInfo_WhenSuccessful() throws Exception { + String docId = "123"; + ObjectNode doc = createDocWithId(docId); + RelatedItemType[] types = new RelatedItemType[]{RelatedItemType.parent}; + + when(metadataUtils.findOne(docId)).thenReturn(metadata); + + Map> associated = new HashMap<>(); + List records = new ArrayList<>(); + AssociatedRecord record = new AssociatedRecord(); + record.setUuid("related-uuid"); + records.add(record); + associated.put(RelatedItemType.parent, records); + + try (MockedStatic mockedMetadataUtils = mockStatic(MetadataUtils.class)) { + mockedMetadataUtils.when(() -> MetadataUtils.getAssociated( + eq(context), eq(metadata), eq(types), anyInt(), anyInt() + )).thenReturn(associated); + + Map params = new HashMap<>(); + params.put("relatedTypes", types); + processor.process(doc, context, params); + + assertTrue(doc.has("related")); + assertNotNull(doc.get("related").get("parent")); + } + } + + @Test + public void process_ShouldSetRelatedToNull_WhenExceptionOccurs() throws Exception { + String docId = "123"; + ObjectNode doc = createDocWithId(docId); + RelatedItemType[] types = new RelatedItemType[]{RelatedItemType.parent}; + + when(metadataUtils.findOne(docId)).thenReturn(metadata); + + try (MockedStatic mockedMetadataUtils = mockStatic(MetadataUtils.class)) { + mockedMetadataUtils.when(() -> MetadataUtils.getAssociated( + any(), any(), any(), anyInt(), anyInt() + )).thenThrow(new RuntimeException("Test exception")); + + Map params = new HashMap<>(); + params.put("relatedTypes", types); + processor.process(doc, context, params); + + assertTrue(doc.has("related")); + assertTrue(doc.get("related").isNull()); + } + } + + @Test + public void process_ShouldHandleMissingId() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + RelatedItemType[] types = new RelatedItemType[]{RelatedItemType.parent}; + + Map params = new HashMap<>(); + params.put("relatedTypes", types); + processor.process(doc, context, params); + + assertTrue(doc.has("related")); + assertTrue(doc.get("related").isNull()); + } + + @Test + public void process_ShouldHandleMissingSource() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + RelatedItemType[] types = new RelatedItemType[]{RelatedItemType.parent}; + + Map params = new HashMap<>(); + params.put("relatedTypes", types); + processor.process(doc, context, params); + + assertTrue(doc.has("related")); + assertTrue(doc.get("related").isNull()); + } + + private ObjectNode createDocWithId(String id) { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.ID, id); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + return doc; + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessorTest.java new file mode 100644 index 000000000000..10e574686fc5 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentRemovePrivilegesProcessorTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.fao.geonet.domain.ReservedOperation; +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class EsDocumentRemovePrivilegesProcessorTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final EsDocumentRemovePrivilegesProcessor processor = new EsDocumentRemovePrivilegesProcessor(); + + @Test + public void testRemovePrivileges() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = doc.putObject("_source"); + + // Add some privileges + source.put("op0", true); + source.put("op1", true); + source.put("op2", false); + source.put("otherField", "value"); + + processor.process(doc, null, Collections.emptyMap()); + + assertNotNull(doc.get("_source")); + assertNull(source.get("op0")); + assertNull(source.get("op1")); + assertNull(source.get("op2")); + assertTrue(source.has("otherField")); + } + + @Test + public void testRemovePrivilegesNoSource() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + doc.put("something", "else"); + + processor.process(doc, null, Collections.emptyMap()); + + assertNull(doc.get("_source")); + assertTrue(doc.has("something")); + } + + @Test + public void testRemovePrivilegesEmptySource() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + doc.putObject("_source"); + + processor.process(doc, null, Collections.emptyMap()); + + assertNotNull(doc.get("_source")); + assertTrue(doc.get("_source").isEmpty()); + } + + @Test + public void testAllReservedOperationsAreRemoved() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = doc.putObject("_source"); + + for (ReservedOperation op : ReservedOperation.values()) { + source.put("op" + op.getId(), true); + } + + processor.process(doc, null, Collections.emptyMap()); + + for (ReservedOperation op : ReservedOperation.values()) { + assertFalse("Privilege op" + op.getId() + " should have been removed", source.has("op" + op.getId())); + } + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessorTest.java new file mode 100644 index 000000000000..5c262f608aa3 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentSelectionInfoProcessorTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Edit; +import org.fao.geonet.constants.Geonet; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class EsDocumentSelectionInfoProcessorTest { + + private EsDocumentSelectionInfoProcessor processor; + private ObjectMapper mapper; + + @Before + public void setUp() { + processor = new EsDocumentSelectionInfoProcessor(); + mapper = new ObjectMapper(); + } + + @Test + public void process_ShouldSetSelectedToTrue_WhenUuidIsSelected() throws Exception { + String uuid = "test-uuid"; + ObjectNode doc = createDocWithUuid(uuid); + Set selections = new HashSet<>(); + selections.add(uuid); + + Map params = new HashMap<>(); + params.put("selections", selections); + processor.process(doc, null, params); + + assertTrue(doc.get(Edit.Info.Elem.SELECTED).asBoolean()); + } + + @Test + public void process_ShouldSetSelectedToFalse_WhenUuidIsNotSelected() throws Exception { + String uuid = "test-uuid"; + ObjectNode doc = createDocWithUuid(uuid); + Set selections = new HashSet<>(); + selections.add("other-uuid"); + + Map params = new HashMap<>(); + params.put("selections", selections); + processor.process(doc, null, params); + + assertFalse(doc.get(Edit.Info.Elem.SELECTED).asBoolean()); + } + + @Test + public void process_ShouldSetSelectedToFalse_WhenUuidIsMissingInDoc() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + doc.set(ObjectNodeUtils.SOURCE_NODE, mapper.createObjectNode()); + Set selections = new HashSet<>(); + selections.add("some-uuid"); + + Map params = new HashMap<>(); + params.put("selections", selections); + processor.process(doc, null, params); + + assertFalse(doc.get(Edit.Info.Elem.SELECTED).asBoolean()); + } + + @Test + public void process_ShouldSetSelectedToFalse_WhenSourceNodeMissing() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + Set selections = Collections.singleton("uuid"); + + Map params = new HashMap<>(); + params.put("selections", selections); + processor.process(doc, null, params); + + assertFalse(doc.get(Edit.Info.Elem.SELECTED).asBoolean()); + } + + private ObjectNode createDocWithUuid(String uuid) { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.UUID, uuid); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + return doc; + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessorTest.java new file mode 100644 index 000000000000..8a8dfff11af6 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsDocumentUserInfoProcessorTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.es.ObjectNodeUtils; +import org.fao.geonet.constants.Edit; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.domain.MetadataSourceInfo; +import org.fao.geonet.domain.ReservedGroup; +import org.fao.geonet.domain.ReservedOperation; +import org.fao.geonet.kernel.AccessManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +public class EsDocumentUserInfoProcessorTest { + + private EsDocumentUserInfoProcessor processor; + private ObjectMapper mapper; + + @Mock + private ServiceContext context; + + @Mock + private AccessManager accessManager; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + processor = new EsDocumentUserInfoProcessor(); + mapper = new ObjectMapper(); + when(context.getBean(AccessManager.class)).thenReturn(accessManager); + } + + @Test + public void process_ShouldSetPermissions_WhenUserIsOwner() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.OWNER, 1); + source.put(Geonet.IndexFieldNames.ID, "123"); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(accessManager.isOwner(eq(context), any(MetadataSourceInfo.class))).thenReturn(true); + when(accessManager.hasReviewPermission(eq(context), eq("123"))).thenReturn(true); + + processor.process(doc, context, Collections.emptyMap()); + + assertTrue(doc.get(Edit.Info.Elem.OWNER).asBoolean()); + assertTrue(doc.get(Edit.Info.Elem.EDIT).asBoolean()); + assertTrue(doc.get(Edit.Info.Elem.REVIEW).asBoolean()); + assertEquals(1, doc.get("ownerId").asInt()); + + // Check reserved operations added to doc + for (ReservedOperation op : ReservedOperation.values()) { + if (op != ReservedOperation.editing) { // editing is not added via addReservedOperation in the original code + assertTrue("Should have " + op.name(), doc.get(op.name()).asBoolean()); + } + } + } + + @Test + public void process_ShouldSetPermissions_WhenUserIsNotOwnerButHasGroups() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put(Geonet.IndexFieldNames.OWNER, 1); + source.put(Geonet.IndexFieldNames.ID, "123"); + + // Add operation permissions to source + source.put("op" + ReservedOperation.view.getId(), 10); // Group 10 has view + source.put("op" + ReservedOperation.editing.getId(), 20); // Group 20 has edit + + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(accessManager.isOwner(eq(context), any(MetadataSourceInfo.class))).thenReturn(false); + when(accessManager.getUserGroups(any(), any(), eq(false))).thenReturn(Collections.singleton(10)); + when(accessManager.getUserGroups(any(), any(), eq(true))).thenReturn(Collections.singleton(20)); + when(accessManager.hasReviewPermission(eq(context), eq("123"))).thenReturn(false); + + processor.process(doc, context, Collections.emptyMap()); + + assertFalse(doc.get(Edit.Info.Elem.OWNER).asBoolean()); + assertTrue(doc.get(Edit.Info.Elem.EDIT).asBoolean()); // has editing group + assertFalse(doc.get(Edit.Info.Elem.REVIEW).asBoolean()); + + assertTrue(doc.get(ReservedOperation.view.name()).asBoolean()); + assertFalse(doc.get(ReservedOperation.download.name()).asBoolean()); + } + + @Test + public void process_ShouldSetIsPublishedToAll_WhenAllGroupHasView() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put("op" + ReservedOperation.view.getId(), ReservedGroup.all.getId()); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(accessManager.isOwner(eq(context), any(MetadataSourceInfo.class))).thenReturn(false); + when(accessManager.getUserGroups(any(), any(), anyBoolean())).thenReturn(Collections.emptySet()); + + processor.process(doc, context, Collections.emptyMap()); + + assertTrue(doc.get(Edit.Info.Elem.IS_PUBLISHED_TO_ALL).asBoolean()); + } + + @Test + public void process_ShouldSetGuestDownload_WhenGuestGroupHasDownload() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + source.put("op" + ReservedOperation.download.getId(), ReservedGroup.guest.getId()); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(accessManager.isOwner(eq(context), any(MetadataSourceInfo.class))).thenReturn(false); + when(accessManager.getUserGroups(any(), any(), anyBoolean())).thenReturn(Collections.emptySet()); + + processor.process(doc, context, Collections.emptyMap()); + + assertTrue(doc.get(Edit.Info.Elem.GUEST_DOWNLOAD).asBoolean()); + } + + + @Test + public void process_ShouldHandleArrayOfGroups() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + ObjectNode source = mapper.createObjectNode(); + ArrayNode viewGroups = mapper.createArrayNode(); + viewGroups.add(10); + viewGroups.add(ReservedGroup.all.getId()); + source.set("op" + ReservedOperation.view.getId(), viewGroups); + doc.set(ObjectNodeUtils.SOURCE_NODE, source); + + when(accessManager.isOwner(eq(context), any(MetadataSourceInfo.class))).thenReturn(false); + when(accessManager.getUserGroups(any(), any(), eq(false))).thenReturn(Collections.singleton(10)); + when(accessManager.getUserGroups(any(), any(), eq(true))).thenReturn(Collections.emptySet()); + + processor.process(doc, context, Collections.emptyMap()); + + assertTrue(doc.get(ReservedOperation.view.name()).asBoolean()); + assertTrue(doc.get(Edit.Info.Elem.IS_PUBLISHED_TO_ALL).asBoolean()); + } + + @Test + public void process_ShouldHandleMissingSourceNode() throws Exception { + ObjectNode doc = mapper.createObjectNode(); + + when(accessManager.isOwner(eq(context), any(MetadataSourceInfo.class))).thenReturn(false); + when(accessManager.getUserGroups(any(), any(), anyBoolean())).thenReturn(Collections.emptySet()); + + processor.process(doc, context, Collections.emptyMap()); + + assertFalse(doc.get(Edit.Info.Elem.OWNER).asBoolean()); + assertFalse(doc.get(Edit.Info.Elem.EDIT).asBoolean()); + assertFalse(doc.get(Edit.Info.Elem.IS_PUBLISHED_TO_ALL).asBoolean()); + } +} diff --git a/services/src/test/java/org/fao/geonet/api/es/processors/response/EsResponseProcessorTest.java b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsResponseProcessorTest.java new file mode 100644 index 000000000000..b38aad6ee4b9 --- /dev/null +++ b/services/src/test/java/org/fao/geonet/api/es/processors/response/EsResponseProcessorTest.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es.processors.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jeeves.constants.Jeeves; +import jeeves.server.UserSession; +import jeeves.server.context.ServiceContext; +import org.fao.geonet.api.ApiUtils; +import org.fao.geonet.api.es.EsSearchEndpoints; +import org.fao.geonet.api.records.model.related.RelatedItemType; +import org.fao.geonet.kernel.SelectionManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import javax.servlet.http.HttpSession; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashSet; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +public class EsResponseProcessorTest { + + @InjectMocks + private EsResponseProcessor esResponseProcessor; + + @Mock + private EsDocumentSelectionInfoProcessor esDocumentSelectionInfoProcessor; + + @Mock + private EsDocumentUserInfoProcessor esDocumentUserInfoProcessor; + + @Mock + private EsDocumentRelatedTypesProcessor esDocumentRelatedTypesProcessor; + + @Mock + private EsDocumentMetadataFiltersProcessor esDocumentMetadataFiltersProcessor; + + @Mock + private EsDocumentRemovePrivilegesProcessor esDocumentRemovePrivilegesProcessor; + + @Mock + private ServiceContext context; + + @Mock + private HttpSession httpSession; + + @Mock + private UserSession userSession; + + @Mock + private SelectionManager selectionManager; + + private final ObjectMapper mapper = new ObjectMapper(); + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + when(httpSession.getAttribute(Jeeves.Elem.SESSION)).thenReturn(userSession); + } + + @Test + public void testProcessResponse_Search() throws Exception { + String jsonResponse = "{\"hits\":{\"hits\":[{\"_source\":{\"uuid\":\"uuid1\",\"documentStandard\":\"iso19139\"}}]}}"; + InputStream inputStream = new ByteArrayInputStream(jsonResponse.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + try (MockedStatic selectionManagerMockedStatic = mockStatic(SelectionManager.class); + MockedStatic apiUtilsMockedStatic = mockStatic(ApiUtils.class)) { + apiUtilsMockedStatic.when(() -> ApiUtils.getUserSession(httpSession)).thenReturn(userSession); + selectionManagerMockedStatic.when(() -> SelectionManager.getManager(userSession)).thenReturn(selectionManager); + when(selectionManager.getSelection(any())).thenReturn(new HashSet<>(Collections.singletonList("uuid1"))); + + esResponseProcessor.processResponse(context, httpSession, inputStream, outputStream, + EsSearchEndpoints.SEARCH_ENDPOINT.toString(), "metadata", true, null); + + verify(esDocumentUserInfoProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + verify(esDocumentSelectionInfoProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + verify(esDocumentMetadataFiltersProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + verify(esDocumentRemovePrivilegesProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + } + + String result = outputStream.toString(StandardCharsets.UTF_8.name()); + assertTrue(result.contains("uuid1")); + } + + @Test + public void testProcessResponse_MSearch() throws Exception { + String jsonResponse = "{\"responses\":[{\"hits\":{\"hits\":[{\"_source\":{\"uuid\":\"uuid1\",\"documentStandard\":\"iso19139\"}}]}}]}"; + InputStream inputStream = new ByteArrayInputStream(jsonResponse.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + try (MockedStatic selectionManagerMockedStatic = mockStatic(SelectionManager.class); + MockedStatic apiUtilsMockedStatic = mockStatic(ApiUtils.class)) { + apiUtilsMockedStatic.when(() -> ApiUtils.getUserSession(httpSession)).thenReturn(userSession); + selectionManagerMockedStatic.when(() -> SelectionManager.getManager(userSession)).thenReturn(selectionManager); + when(selectionManager.getSelection(any())).thenReturn(new HashSet<>()); + + esResponseProcessor.processResponse(context, httpSession, inputStream, outputStream, + EsSearchEndpoints.MULTISEARCH_ENDPOINT.toString(), "metadata", true, null); + + verify(esDocumentUserInfoProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + verify(esDocumentSelectionInfoProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + verify(esDocumentRemovePrivilegesProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + // Metadata filters are NOT applied in msearch based on the code logic (currently) + verify(esDocumentMetadataFiltersProcessor, never()).process(any(), any(), any()); + } + + String result = outputStream.toString(StandardCharsets.UTF_8.name()); + assertTrue(result.contains("uuid1")); + } + + @Test + public void testProcessResponse_WithRelatedTypes() throws Exception { + String jsonResponse = "{\"hits\":{\"hits\":[{\"_source\":{\"uuid\":\"uuid1\",\"documentStandard\":\"iso19139\"}}]}}"; + InputStream inputStream = new ByteArrayInputStream(jsonResponse.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + RelatedItemType[] relatedTypes = new RelatedItemType[]{RelatedItemType.associated}; + + try (MockedStatic selectionManagerMockedStatic = mockStatic(SelectionManager.class); + MockedStatic apiUtilsMockedStatic = mockStatic(ApiUtils.class)) { + apiUtilsMockedStatic.when(() -> ApiUtils.getUserSession(httpSession)).thenReturn(userSession); + selectionManagerMockedStatic.when(() -> SelectionManager.getManager(userSession)).thenReturn(selectionManager); + when(selectionManager.getSelection(any())).thenReturn(new HashSet<>()); + + esResponseProcessor.processResponse(context, httpSession, inputStream, outputStream, + EsSearchEndpoints.SEARCH_ENDPOINT.toString(), "metadata", false, relatedTypes); + + verify(esDocumentRelatedTypesProcessor, times(1)).process(any(ObjectNode.class), eq(context), anyMap()); + } + } + + @Test + public void testProcessResponse_MetadataFiltersException() throws Exception { + String jsonResponse = "{\"hits\":{\"hits\":[{\"_source\":{\"uuid\":\"uuid1\",\"documentStandard\":\"iso19139\"}}]}}"; + InputStream inputStream = new ByteArrayInputStream(jsonResponse.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + doThrow(new IllegalArgumentException("Filter error")).when(esDocumentMetadataFiltersProcessor) + .process(any(ObjectNode.class), eq(context), anyMap()); + + try (MockedStatic selectionManagerMockedStatic = mockStatic(SelectionManager.class); + MockedStatic apiUtilsMockedStatic = mockStatic(ApiUtils.class)) { + apiUtilsMockedStatic.when(() -> ApiUtils.getUserSession(httpSession)).thenReturn(userSession); + selectionManagerMockedStatic.when(() -> SelectionManager.getManager(userSession)).thenReturn(selectionManager); + when(selectionManager.getSelection(any())).thenReturn(new HashSet<>()); + + // Should not throw exception, just log it + esResponseProcessor.processResponse(context, httpSession, inputStream, outputStream, + EsSearchEndpoints.SEARCH_ENDPOINT.toString(), "metadata", true, null); + + verify(esDocumentMetadataFiltersProcessor).process(any(ObjectNode.class), eq(context), anyMap()); + verify(esDocumentRemovePrivilegesProcessor).process(any(ObjectNode.class), eq(context), anyMap()); + } + } +} From 3deb94c9bbc35792a9b223b8e83c157790fe0c0a Mon Sep 17 00:00:00 2001 From: josegar74 Date: Tue, 9 Jun 2026 10:03:02 +0200 Subject: [PATCH 3/3] Elasticsearch HTTP proxy refactor / fix tests --- .../src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java b/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java index 6dfd288c7bc1..65cda50f2934 100644 --- a/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java +++ b/services/src/test/java/org/fao/geonet/api/es/EsHTTPProxyTest.java @@ -55,6 +55,9 @@ protected HttpURLConnection openConnection(String sUrl) throws IOException { @Mock private EsResponseProcessor responseProcessor; + @Mock + private EsResponseContentTypeValidator contentTypeValidator; + @Mock private EsQueryProcessor queryPreprocessor; @@ -85,6 +88,7 @@ public void setUp() { ReflectionTestUtils.setField(esHTTPProxy, "proxyHeadersAllowedList", new String[]{"content-type"}); ReflectionTestUtils.setField(esHTTPProxy, "client", esRestClient); ReflectionTestUtils.setField(esHTTPProxy, "responseProcessor", responseProcessor); + ReflectionTestUtils.setField(esHTTPProxy, "contentTypeValidator", contentTypeValidator); ReflectionTestUtils.setField(esHTTPProxy, "queryPreprocessor", queryPreprocessor); lenient().when(esRestClient.getServerUrl()).thenReturn(SERVER_URL);