diff --git a/Dockerfile b/Dockerfile index 63083238e1c..76da5e5645c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM eclipse-temurin:21-jre -ARG VERSION="7.0.1" +ARG VERSION="7.0.2" LABEL authors="Netgrif " \ org.opencontainers.image.authors="NETGRIF " \ diff --git a/Dockerfile.multi-stage b/Dockerfile.multi-stage index aa72d96e9aa..37d00a3225f 100644 --- a/Dockerfile.multi-stage +++ b/Dockerfile.multi-stage @@ -31,7 +31,7 @@ RUN mvn -B -e -DskipTests -P docker-build clean install # prepare runtime FROM eclipse-temurin:21-jre-jammy -ARG VERSION="7.0.1" +ARG VERSION="7.0.2" LABEL authors="Netgrif " \ org.opencontainers.image.authors="NETGRIF " \ diff --git a/application-engine/pom.xml b/application-engine/pom.xml index 92e4c07fbe0..98600ef1fb9 100644 --- a/application-engine/pom.xml +++ b/application-engine/pom.xml @@ -6,7 +6,7 @@ com.netgrif application-engine-parent - 7.0.1 + 7.0.2 application-engine diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/AsyncRunner.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/AsyncRunner.groovy index 86e5bd7e1d5..ea8600a5c9a 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/AsyncRunner.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/AsyncRunner.groovy @@ -1,18 +1,73 @@ package com.netgrif.application.engine -import org.springframework.scheduling.annotation.Async +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.core.task.TaskExecutor +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicBoolean + @Service class AsyncRunner { - @Async + private final TaskExecutor actionsExecutor + + AsyncRunner(@Qualifier("taskExecutor") TaskExecutor actionsExecutor) { + this.actionsExecutor = actionsExecutor + } + void run(Closure closure) { - closure() + ActionDelegate actionDelegate = findActionDelegate(closure) + actionDelegate?.retainForAsyncExecution() + AtomicBoolean released = new AtomicBoolean() + + Runnable task = { + try { + closure() + } finally { + release(actionDelegate, released) + } + } as Runnable + + try { + execute(task) + } catch (Throwable throwable) { + release(actionDelegate, released) + throw throwable + } } - @Async void execute(final Runnable runnable) { - runnable.run() + actionsExecutor.execute(runnable) + } + + private static void release(ActionDelegate actionDelegate, AtomicBoolean released) { + if (actionDelegate != null && released.compareAndSet(false, true)) { + actionDelegate.releaseAfterAsyncExecution() + } + } + + private static ActionDelegate findActionDelegate(Closure closure) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()) + return findActionDelegate(closure, visited) + } + + private static ActionDelegate findActionDelegate(Object candidate, Set visited) { + if (candidate == null || !visited.add(candidate)) { + return null + } + if (candidate instanceof ActionDelegate) { + return candidate + } + if (!(candidate instanceof Closure)) { + return null + } + + Closure nestedClosure = (Closure) candidate + return findActionDelegate(nestedClosure.delegate, visited) + ?: findActionDelegate(nestedClosure.owner, visited) + ?: findActionDelegate(nestedClosure.thisObject, visited) } -} \ No newline at end of file +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy index 74edc3612e3..8ea16777a21 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy @@ -16,6 +16,7 @@ import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetServi import com.querydsl.core.types.Predicate import groovy.util.logging.Slf4j import org.bson.types.ObjectId +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.data.mongodb.core.BulkOperations import org.springframework.data.mongodb.core.FindAndReplaceOptions import org.springframework.data.mongodb.core.MongoTemplate @@ -65,7 +66,7 @@ class CaseMigrationHelper extends AbstractMigrationHelper { * @param mongoTemplate MongoTemplate to interact with MongoDB. * @param migrationConfigurationProperties Properties for migration configuration, including cases. */ - CaseMigrationHelper(MongoTemplate mongoTemplate, + CaseMigrationHelper(@Qualifier("mongoTemplate") MongoTemplate mongoTemplate, MigrationProperties migrationProperties, IPetriNetService petriNetService, IElasticCaseService elasticCaseService, diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy index 81f96a7b8e9..3cc7935e925 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy @@ -20,6 +20,7 @@ import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetServi import groovy.util.logging.Slf4j import org.apache.tomcat.util.http.fileupload.IOUtils import org.springframework.beans.factory.ObjectFactory +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.core.io.ClassPathResource import org.springframework.core.io.Resource import org.springframework.data.domain.Pageable @@ -83,7 +84,7 @@ class PetriNetMigrationHelper extends AbstractMigrationHelper { * @param importerProvider the {@link ObjectFactory} that supplies {@link Importer} instances for importing Petri Net models from various sources * @param userService the {@link UserService} for managing user-related operations, including retrieving system user for Petri Net imports */ - PetriNetMigrationHelper(MongoTemplate mongoTemplate, + PetriNetMigrationHelper(@Qualifier("mongoTemplate") MongoTemplate mongoTemplate, MigrationProperties migrationProperties, IPetriNetService petriNetService, ProcessRoleRepository processRoleRepository, diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy index 8730ed1d25f..1c9fc6c79be 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy @@ -15,6 +15,7 @@ import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetServi import com.netgrif.application.engine.workflow.service.interfaces.ITaskService import com.querydsl.core.types.Predicate import groovy.util.logging.Slf4j +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.data.mongodb.core.BulkOperations import org.springframework.data.mongodb.core.MongoTemplate import org.springframework.data.mongodb.core.query.Criteria @@ -70,7 +71,7 @@ class TaskMigrationHelper extends AbstractMigrationHelper { * * @param mongoTemplate the {@link MongoTemplate} to use for interacting with MongoDB */ - TaskMigrationHelper(MongoTemplate mongoTemplate, + TaskMigrationHelper(@Qualifier("mongoTemplate") MongoTemplate mongoTemplate, MigrationProperties migrationProperties, IPetriNetService petriNetService, ITaskService taskService, diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy index 885fee69037..0e08f837846 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy @@ -240,6 +240,10 @@ class ActionDelegate extends DelegateExpando { FieldActionsRunner actionsRunner List outcomes + private int pendingAsyncExecutions + private boolean executionFinished + private boolean executionStateCleared + def init(Action action, Case useCase, Optional task, FieldActionsRunner actionsRunner, Map params = [:]) { this.action = action this.useCase = useCase @@ -254,7 +258,32 @@ class ActionDelegate extends DelegateExpando { this.Plugin = new PluginHolder() } - void clearAfterExecution() { + synchronized void retainForAsyncExecution() { + if (executionStateCleared) { + throw new IllegalStateException("Action execution state has already been cleared") + } + pendingAsyncExecutions++ + } + + synchronized void releaseAfterAsyncExecution() { + if (pendingAsyncExecutions == 0) { + throw new IllegalStateException("No asynchronous action execution is pending") + } + pendingAsyncExecutions-- + clearExecutionStateIfPossible() + } + + synchronized void clearAfterExecution() { + executionFinished = true + clearExecutionStateIfPossible() + } + + private void clearExecutionStateIfPossible() { + if (!executionFinished || pendingAsyncExecutions != 0 || executionStateCleared) { + return + } + executionStateCleared = true + this.action = null this.useCase = null this.task = null diff --git a/application-engine/src/main/java/com/netgrif/application/engine/configuration/MongoClientConfiguration.java b/application-engine/src/main/java/com/netgrif/application/engine/configuration/MongoClientConfiguration.java index 305789a7e55..ed10630fc3d 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/configuration/MongoClientConfiguration.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/configuration/MongoClientConfiguration.java @@ -4,9 +4,7 @@ import com.mongodb.connection.*; import com.netgrif.application.engine.configuration.properties.DataConfigurationProperties; import org.jetbrains.annotations.NotNull; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.*; import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; diff --git a/application-engine/src/main/java/com/netgrif/application/engine/configuration/groovy/GroovyShellConfiguration.java b/application-engine/src/main/java/com/netgrif/application/engine/configuration/groovy/GroovyShellConfiguration.java index 919ae67a91e..0a7b930e790 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/configuration/groovy/GroovyShellConfiguration.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/configuration/groovy/GroovyShellConfiguration.java @@ -40,7 +40,11 @@ protected String[] getDefaultEngineImports() { return new String[]{ "com.netgrif.application.engine.objects", "com.netgrif.application.engine.adapter.spring", - "java.time" + "com.netgrif.application.engine.objects.petrinet.domain.dataset", + "org.bson.types", + "java.time", + "java.util", + "java.util.stream" }; } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java b/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java index 7716fec4609..41adab2ab93 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java @@ -18,7 +18,7 @@ */ @Data @Configuration -@ConfigurationProperties(prefix = "nae.migration") +@ConfigurationProperties(prefix = "netgrif.engine.migration") public class MigrationProperties { /** diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java index 199b1251a7d..36251bc6ea2 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java @@ -10,6 +10,7 @@ import co.elastic.clients.elasticsearch.core.bulk.BulkOperation; import com.netgrif.application.engine.configuration.properties.DataConfigurationProperties; import com.netgrif.application.engine.elastic.domain.BulkOperationWrapper; +import com.netgrif.application.engine.elastic.service.model.FullTextField; import com.netgrif.application.engine.objects.auth.domain.LoggedUser; import com.netgrif.application.engine.objects.elastic.domain.ElasticCase; import com.netgrif.application.engine.elastic.domain.ElasticCaseRepository; @@ -42,6 +43,7 @@ import java.util.*; import java.util.function.BinaryOperator; +import java.util.regex.Matcher; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -59,7 +61,7 @@ public class ElasticCaseService extends ElasticViewPermissionService implements protected DataConfigurationProperties.ElasticsearchProperties elasticProperties; protected IPetriNetService petriNetService; protected IWorkflowService workflowService; - protected IElasticCasePrioritySearch iElasticCasePrioritySearch; + protected IElasticCasePrioritySearch elasticCasePrioritySearch; protected ApplicationEventPublisher publisher; protected ElasticQueueManager caseElasticIndexQueueManager; protected ElasticQueueManager caseElasticDeleteQueueManager; @@ -70,7 +72,7 @@ public ElasticCaseService(ElasticCaseRepository repository, DataConfigurationProperties.ElasticsearchProperties elasticProperties, @Lazy IPetriNetService petriNetService, @Lazy IWorkflowService workflowService, - IElasticCasePrioritySearch iElasticCasePrioritySearch, + IElasticCasePrioritySearch elasticCasePrioritySearch, ApplicationEventPublisher publisher, ElasticsearchClient elasticsearchClient) { this.repository = repository; @@ -79,7 +81,7 @@ public ElasticCaseService(ElasticCaseRepository repository, this.elasticProperties = elasticProperties; this.petriNetService = petriNetService; this.workflowService = workflowService; - this.iElasticCasePrioritySearch = iElasticCasePrioritySearch; + this.elasticCasePrioritySearch = elasticCasePrioritySearch; this.publisher = publisher; this.caseElasticIndexQueueManager = new ElasticQueueManager(elasticProperties, elasticsearchClient, publisher); this.caseElasticDeleteQueueManager = new ElasticQueueManager(elasticProperties, elasticsearchClient, publisher); @@ -413,15 +415,37 @@ protected void buildTagsQuery(CaseSearchRequest request, BoolQuery.Builder query } protected void buildFullTextQuery(CaseSearchRequest request, BoolQuery.Builder query) { - if (request.fullText == null || request.fullText.isEmpty()) { + if (request.fullText == null || request.fullText.isBlank()) { return; } - // TODO: improvement? wildcard does not scale good - //String searchText = elasticsearchProperties.isAnalyzerEnabled() ? request.fullText : "*" + request.fullText + "*"; - String searchText = "*" + request.fullText + "*"; - QueryStringQuery fullTextQuery = QueryStringQuery.of(builder -> builder.fields(iElasticCasePrioritySearch.fullTextFields()).query(searchText)); - query.must(fullTextQuery._toQuery()); + List fullTextTerms = normalizeFullTextSearch(request.fullText); + if (fullTextTerms.isEmpty()) { + return; + } + + List fullTextFields = elasticCasePrioritySearch.fullTextFields().stream() + .map(this::parseFullTextField) + .toList(); + + BoolQuery.Builder fullTextQuery = new BoolQuery.Builder(); + + fullTextTerms.forEach(term -> { + BoolQuery.Builder termQuery = new BoolQuery.Builder(); + String wildcardValue = "*" + escapeWildcardValue(term) + "*"; + + fullTextFields.forEach(fullTextField -> termQuery.should(QueryBuilders.wildcard(builder -> builder + .field(fullTextField.field()) + .value(wildcardValue) + .caseInsensitive(true) + .boost(fullTextField.boost()) + ))); + + termQuery.minimumShouldMatch("1"); + fullTextQuery.must(termQuery.build()._toQuery()); + }); + + query.must(fullTextQuery.build()._toQuery()); } /** @@ -531,4 +555,44 @@ private BulkOperation createIndexOperation(ElasticCase useCase) { .id(useCase.getId()) .document(template.getElasticsearchConverter().mapObject(useCase)))); } + + private List normalizeFullTextSearch(String fullText) { + return Arrays.stream(Matcher.quoteReplacement(fullText) + .replace("\\\\", "") + .replaceAll("\\s+", " ") + .trim() + .split("\\s+")) + .map(String::trim) + .map(this::removeDanglingEscapeCharacters) + .filter(term -> !term.isBlank()) + .toList(); + } + + private String removeDanglingEscapeCharacters(String term) { + return term.replaceAll("\\\\+$", ""); + } + + private FullTextField parseFullTextField(String fieldDefinition) { + String[] parts = fieldDefinition.split("\\^", 2); + String field = parts[0].trim(); + float boost = 1.0f; + + if (parts.length == 2 && !parts[1].isBlank()) { + try { + boost = Float.parseFloat(parts[1].trim()); + boost = Float.isFinite(boost) && boost > 0 ? boost : 1.0f; + } catch (NumberFormatException e) { + log.warn("Invalid boost [{}] in fulltext field definition [{}]. Using default boost 1.0.", parts[1], fieldDefinition); + } + } + + return new FullTextField(field, boost); + } + + private String escapeWildcardValue(String value) { + return value + .replace("\\", "\\\\") + .replace("*", "\\*") + .replace("?", "\\?"); + } } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/model/FullTextField.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/model/FullTextField.java new file mode 100644 index 00000000000..bcaddca2d16 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/model/FullTextField.java @@ -0,0 +1,4 @@ +package com.netgrif.application.engine.elastic.service.model; + +public record FullTextField(String field, float boost) { +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java b/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java index 4da799201d4..c75fc1640b1 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java @@ -1,24 +1,41 @@ package com.netgrif.application.engine.event; +import com.netgrif.application.engine.configuration.properties.ActionsProperties; import groovy.lang.GroovyShell; +import lombok.extern.slf4j.Slf4j; import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.control.customizers.ImportCustomizer; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReaderFactory; import org.springframework.stereotype.Service; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +@Slf4j @Service public class GroovyShellFactory implements IGroovyShellFactory { + private static final List ACTION_IMPORT_PACKAGES = List.of( + "com.netgrif.application.engine.objects.*", + "com.netgrif.application.engine.workflow.domain.*", + "com.netgrif.application.engine.adapter.spring.*" + ); + @Autowired private CompilerConfiguration configuration; + @Autowired + private ActionsProperties actionsProperties; + private volatile GroovyShell shell; @Override @@ -30,7 +47,7 @@ public GroovyShell getGroovyShell() { if (local == null) { ImportCustomizer importCustomizer = new ImportCustomizer(); - Set classNames = findAllClassesUsingClassLoader("com.netgrif.application.engine.workflow.domain"); + Set classNames = findAllActionImportClasses(); importCustomizer.addImports(classNames.toArray(new String[0])); configuration.addCompilationCustomizers(importCustomizer); @@ -43,21 +60,101 @@ public GroovyShell getGroovyShell() { return local; } - private Set findAllClassesUsingClassLoader(String packageName) { - String path = packageName.replace(".", "/"); - InputStream stream = ClassLoader.getSystemClassLoader().getResourceAsStream(path); - if (stream == null) { - return Set.of(); + private Set findAllActionImportClasses() { + Set configuredImportNames = actionsProperties.getImports().stream() + .map(this::simpleName) + .collect(Collectors.toSet()); + + Set> classes = ACTION_IMPORT_PACKAGES.stream() + .flatMap(packageName -> findAllClassesUsingClassLoader(packageName).stream()) + .map(this::loadClass) + .collect(Collectors.toCollection(HashSet::new)); + + return classes.stream() + .collect(Collectors.groupingBy(Class::getSimpleName)) + .entrySet().stream() + .filter(entry -> !configuredImportNames.contains(entry.getKey())) + .map(entry -> selectActionImport(entry.getValue())) + .flatMap(Optional::stream) + .map(Class::getName) + .collect(Collectors.toSet()); + } + + private String simpleName(String className) { + return className.substring(className.lastIndexOf('.') + 1); + } + + private Optional> selectActionImport(List> candidates) { + int highestSpecificity = candidates.stream() + .mapToInt(candidate -> importSpecificity(candidate, candidates)) + .max() + .orElseThrow(); + + List> mostSpecificCandidates = candidates.stream() + .filter(candidate -> importSpecificity(candidate, candidates) == highestSpecificity) + .toList(); + + if (mostSpecificCandidates.size() != 1) { + List collidingClassNames = candidates.stream() + .map(Class::getName) + .sorted() + .toList(); + log.warn("Skipping automatic action import for ambiguous class name [{}]. " + + "Conflicting candidates: {}. Configure an explicit import to resolve the conflict.", + candidates.getFirst().getSimpleName(), collidingClassNames); + return Optional.empty(); + } + + return Optional.of(mostSpecificCandidates.getFirst()); + } + + private int importSpecificity(Class candidate, List> candidates) { + return (int) candidates.stream() + .filter(other -> other != candidate && other.isAssignableFrom(candidate)) + .count(); + } + + private Class loadClass(String className) { + try { + return Class.forName(className, false, getClass().getClassLoader()); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Failed to load discovered action import class " + className, e); } + } - try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { - return reader.lines() - .filter(line -> line.endsWith(".class")) - .map(line -> packageName + "." + line.substring(0, line.lastIndexOf('.'))) - .collect(Collectors.toSet()); + private Set findAllClassesUsingClassLoader(String packagePattern) { + boolean recursive = packagePattern.endsWith(".*"); + String packageName = recursive + ? packagePattern.substring(0, packagePattern.length() - 2) + : packagePattern; + return findAllClassesUsingClassLoader(packageName, recursive); + } + + private Set findAllClassesUsingClassLoader(String packageName, boolean recursive) { + String path = packageName.replace(".", "/"); + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(getClass().getClassLoader()); + MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver); + String classPattern = recursive ? "/**/*.class" : "/*.class"; + + try { + Resource[] resources = resolver.getResources( + ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + path + classPattern + ); + Set classNames = new HashSet<>(); + for (Resource resource : resources) { + if (resource.getDescription().contains("test-classes")) { + continue; + } + String className = metadataReaderFactory.getMetadataReader(resource) + .getClassMetadata() + .getClassName(); + if (!className.contains("$") && !className.endsWith("package-info") && !className.endsWith("module-info")) { + classNames.add(className); + } + } + return classNames; } catch (IOException e) { - e.printStackTrace(); - return Set.of(); + throw new IllegalStateException("Failed to discover classes in package " + packageName, e); } } } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/petrinet/domain/roles/ProcessRoleRepository.java b/application-engine/src/main/java/com/netgrif/application/engine/petrinet/domain/roles/ProcessRoleRepository.java index 236885d1411..fcaba860520 100755 --- a/application-engine/src/main/java/com/netgrif/application/engine/petrinet/domain/roles/ProcessRoleRepository.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/petrinet/domain/roles/ProcessRoleRepository.java @@ -126,7 +126,7 @@ default Optional findByCompositeId(String compositeId) { if (parts.length == 2) { String networkId = parts[0]; ObjectId objectId = new ObjectId(parts[1]); - return findByNetworkIdAndObjectId(networkId, objectId); + return findByNetworkIdentifierAndObjectId(networkId, objectId); } else { return findByIdObjectId(new ObjectId(compositeId)); } @@ -138,10 +138,23 @@ default Optional findByCompositeId(String compositeId) { * @param networkId the short process ID * @param objectId the object ID * @return an {@link Optional} containing the found {@link ProcessRole}, if any + * + * @deprecated since 7.0.2, use {@link #findByNetworkIdentifierAndObjectId(String, ObjectId)} instead */ + @Deprecated(since = "7.0.2") @Query("{ '_id.shortProcessId': ?0, '_id.objectId': ?1 }") Optional findByNetworkIdAndObjectId(String networkId, ObjectId objectId); + /** + * Finds a {@link ProcessRole} by a network ID and object ID. + * + * @param networkIdentifier the short process ID + * @param objectId the object ID + * @return an {@link Optional} containing the found {@link ProcessRole}, if any + */ + @Query("{ '_id.shortProcessIdentifier': ?0, '_id.objectId': ?1 }") + Optional findByNetworkIdentifierAndObjectId(String networkIdentifier, ObjectId objectId); + /** * Finds all {@link ProcessRole} entities by a collection of composite resource IDs. * diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java index 306bf0b83d4..4fdb121d904 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java @@ -38,14 +38,32 @@ default Optional findById(String compositeId) { if (parts.length == 2) { String networkId = parts[0]; ObjectId objectId = new ObjectId(parts[1]); - return findByNetworkIdAndObjectId(networkId, objectId); + return findByNetworkIdentifierAndObjectId(networkId, objectId); } else { return findByIdObjectId(new ObjectId(compositeId)); } } + + /** + * @deprecated since 7.0.2, use {@link #findByNetworkIdentifierAndObjectId(String, ObjectId)} + */ + @Deprecated(since = "7.0.2") @Query("{ '_id.shortProcessId': ?0, '_id.objectId': ?1 }") - Optional findByNetworkIdAndObjectId(String ProcessId, ObjectId objectId); + Optional findByNetworkIdAndObjectId(String processId, ObjectId objectId); + + /** + * Finds a case by its network identifier and MongoDB object ID. + *

+ * This method queries cases using the shortProcessIdentifier field in the composite ID. + * + * @param processIdentifier the short process identifier (network identifier) of the case + * @param objectId the MongoDB object ID of the case + * @return an Optional containing the case if found, or empty if not found + */ + @Query("{ '_id.shortProcessIdentifier': ?0, '_id.objectId': ?1 }") + Optional findByNetworkIdentifierAndObjectId(String processIdentifier, ObjectId objectId); + @Override default void customize(QuerydslBindings bindings, QCase qCase) { diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java index 70505e4c9a2..0644139bad9 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java @@ -57,7 +57,7 @@ public void onAfterDelete(AfterDeleteEvent event) { return; } - String objectId = ((Document)document.get("_id")).get("shortProcessId") + "-" + ((Document)document.get("_id")).get("objectId").toString(); + String objectId = ((Document)document.get("_id")).get("shortProcessIdentifier") + "-" + ((Document)document.get("_id")).get("objectId").toString(); service.remove(objectId); } } diff --git a/application-engine/src/main/resources/application-old-dev.properties b/application-engine/src/main/resources/application-old-dev.properties index 7f749349c72..cb334ce57d8 100644 --- a/application-engine/src/main/resources/application-old-dev.properties +++ b/application-engine/src/main/resources/application-old-dev.properties @@ -25,7 +25,7 @@ nae.storage.clean=true nae.admin.password=password springdoc.swagger-ui.enabled=true -nae.security.server-patterns=/api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/v3/api-docs,/v3/api-docs/**,/swagger-ui.html,/swagger-ui/**,/api/public/**,/manage/** +nae.security.server-patterns=/api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/v3/api-docs,/v3/api-docs/**,/swagger-ui.html,/swagger-ui/**,/api/public/**,/manage/health logging.level.org.springframework.data.elasticsearch.core=info logging.level.com.netgrif.application.engine.elastic.service=info @@ -43,6 +43,7 @@ nae.cache.petriNetCache=petriNetCache # Actuator management.health.ldap.enabled=false management.health.mail.enabled=false +management.endpoint.shutdown.enabled=false #logging.level.root=debug @@ -50,4 +51,4 @@ management.health.mail.enabled=false netgrif.engine.storage.minio.enabled=true nae.storage.minio.hosts.host_1.host=http://127.0.0.1:9000 nae.storage.minio.hosts.host_1.user=root -nae.storage.minio.hosts.host_1.password=password \ No newline at end of file +nae.storage.minio.hosts.host_1.password=password diff --git a/application-engine/src/main/resources/application-old.properties b/application-engine/src/main/resources/application-old.properties index d7b4f6662c6..63efcfc2526 100644 --- a/application-engine/src/main/resources/application-old.properties +++ b/application-engine/src/main/resources/application-old.properties @@ -88,8 +88,8 @@ nae.security.limits.email-block-time-type=DAYS nae.security.jwt.expiration=900000 nae.security.jwt.algorithm=RSA nae.security.jwt.private-key=file:src/main/resources/certificates/private.der -nae.security.server-patterns=/api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/api/public/**,/v3/api-docs/public,/manage/** -nae.security.anonymous-exceptions=/api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/manage/** +nae.security.server-patterns=/api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/api/public/**,/v3/api-docs/public,/manage/health +nae.security.anonymous-exceptions=/api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/manage/health springdoc.swagger-ui.enabled=false # Quartz (this config overwrites quartz.properties resource file) @@ -180,8 +180,8 @@ netgrif.engine.storage.minio.enabled=false # Actuator management.endpoints.web.base-path=/manage management.endpoints.web.path-mapping.prometheus=metric -management.endpoints.web.exposure.exclude=shutdown -management.endpoints.web.exposure.include=* +management.endpoints.web.exposure.include=health,info,metrics,prometheus,beans,env,threaddump,loggers,logfile,logfiles,caches,conditions,configprops,mappings,scheduledtasks,httpexchanges,startup,nodeinfo,elasticnodes,netgriftracing +management.endpoint.shutdown.enabled=false management.endpoint.status.up-statuses=paused,maintenance,running management.endpoint.loggers.enabled=true management.endpoint.health.show-details=when_authorized diff --git a/application-engine/src/main/resources/application.yaml b/application-engine/src/main/resources/application.yaml index 30df7a68c5d..545d60b4978 100644 --- a/application-engine/src/main/resources/application.yaml +++ b/application-engine/src/main/resources/application.yaml @@ -121,8 +121,12 @@ netgrif: web: base-path: /manage exposure: - include: "health,info,metrics,loggers,env,beans,threaddump,heapdump,mappings,conditions,configprops,scheduledtasks,caches,flyway,liquibase,prometheus" + include: "health,info,metrics,prometheus,beans,env,threaddump,heapdump,loggers,logfile,logfiles,caches,conditions,configprops,mappings,scheduledtasks,httpexchanges,startup,nodeinfo,elasticnodes,netgriftracing" endpoint: + shutdown: + enabled: false + heapdump: + access: unrestricted health: show-details: when_authorized show-components: when_authorized diff --git a/application-engine/src/test/groovy/com/netgrif/application/engine/TestHelper.groovy b/application-engine/src/test/groovy/com/netgrif/application/engine/TestHelper.groovy index d8679413385..6e26c217b21 100644 --- a/application-engine/src/test/groovy/com/netgrif/application/engine/TestHelper.groovy +++ b/application-engine/src/test/groovy/com/netgrif/application/engine/TestHelper.groovy @@ -28,7 +28,7 @@ class TestHelper { private SuperCreatorRunner superCreator @Autowired - private MongoTemplate template + private MongoTemplate mongoTemplate @Autowired private ElasticIndexService indexService @@ -118,7 +118,7 @@ class TestHelper { while (true) { try { List collections = mongoCollections() - collections.each { template.dropCollection(it) } + collections.each { mongoTemplate.dropCollection(it) } List remainingCollections = mongoCollections() if (!remainingCollections.isEmpty()) { if (++attempts >= MONGO_READY_ATTEMPTS) { @@ -138,7 +138,7 @@ class TestHelper { } private List mongoCollections() { - return template.db.listCollectionNames() + return mongoTemplate.db.listCollectionNames() .into(new ArrayList()) .findAll { !it.startsWith("system.") } } diff --git a/application-engine/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy b/application-engine/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy index c5f0fd0ec24..97aedd3daf0 100644 --- a/application-engine/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy +++ b/application-engine/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy @@ -6,17 +6,24 @@ import com.icegreen.greenmail.util.ServerSetup import com.netgrif.application.engine.TestHelper import com.netgrif.application.engine.adapter.spring.auth.domain.AuthorityImpl import com.netgrif.application.engine.adapter.spring.workflow.domain.QCase +import com.netgrif.application.engine.configuration.properties.SecurityConfigurationProperties import com.netgrif.application.engine.auth.service.UserService import com.netgrif.application.engine.auth.web.requestbodies.NewUserRequest -import com.netgrif.application.engine.configuration.properties.SecurityConfigurationProperties import com.netgrif.application.engine.objects.auth.constants.UserConstants import com.netgrif.application.engine.objects.auth.domain.AbstractUser import com.netgrif.application.engine.objects.auth.domain.ActorTransformer +import com.netgrif.application.engine.objects.petrinet.domain.VersionType import com.netgrif.application.engine.objects.petrinet.domain.dataset.FileFieldValue import com.netgrif.application.engine.objects.workflow.domain.Case +import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.caseoutcomes.CreateCaseEventOutcome +import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.petrinetoutcomes.ImportPetriNetEventOutcome import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate +import com.netgrif.application.engine.petrinet.params.ImportPetriNetParams +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService import com.netgrif.application.engine.startup.runner.DefaultFiltersRunner import com.netgrif.application.engine.startup.runner.FilterRunner +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner +import com.netgrif.application.engine.workflow.params.CreateCaseParams import com.netgrif.application.engine.workflow.service.interfaces.IFilterImportExportService import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService import com.netgrif.application.engine.workflow.web.responsebodies.MessageResource @@ -57,15 +64,21 @@ class ActionDelegateTest { @Autowired private DefaultFiltersRunner defaultFiltersRunner - @Autowired - private IWorkflowService workflowService - @Autowired private UserService userService @Autowired private SecurityConfigurationProperties.WebProperties webProperties + @Autowired + private IPetriNetService petriNetService + + @Autowired + private IWorkflowService workflowService + + @Autowired + private SuperCreatorRunner superCreator + private AbstractUser systemUser @BeforeEach @@ -166,4 +179,20 @@ class ActionDelegateTest { assert actionDelegate.makeUrl(webProperties.publicWeb.url, identifier) == url assert actionDelegate.makeUrl("test.netgrif.com/public", "identifier") == "test.netgrif.com/public/${getEncoder().encodeToString(identifier.bytes)}" } + + @Test + void testAsyncRunAction() { + ImportPetriNetEventOutcome net = petriNetService.importPetriNet(ImportPetriNetParams.with() + .xmlFile(new FileInputStream("src/test/resources/petriNets/async_run.xml")) + .releaseType(VersionType.MAJOR) + .author(superCreator.getLoggedSuper()) + .build()) + assert net.getNet() != null + CreateCaseEventOutcome outcome = workflowService.createCase(CreateCaseParams.with() + .processId(net.getNet().getStringId()) + .title("Test title") + .author(userService.getLoggedOrSystem()) + .build()) + assert outcome.getCase() != null + } } diff --git a/application-engine/src/test/groovy/com/netgrif/application/engine/action/AssignRemoveTest.groovy b/application-engine/src/test/groovy/com/netgrif/application/engine/action/AssignRemoveTest.groovy index fb4f770ec46..5797a309360 100644 --- a/application-engine/src/test/groovy/com/netgrif/application/engine/action/AssignRemoveTest.groovy +++ b/application-engine/src/test/groovy/com/netgrif/application/engine/action/AssignRemoveTest.groovy @@ -80,7 +80,7 @@ class AssignRemoveTest { [userAuthorities.get("user")] as Authority[], [] as ProcessRole[]) def loggedUser = ActorTransformer.toLoggedUser(testUser) - auth = new UsernamePasswordAuthenticationToken(loggedUser, "password", loggedUser.authorities) + auth = new UsernamePasswordAuthenticationToken(loggedUser, "password", loggedUser.authoritySet) SecurityContextHolder.getContext().setAuthentication(auth) Set actionRoleIds = net.roles.values() diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java new file mode 100644 index 00000000000..69aff80379a --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java @@ -0,0 +1,233 @@ +package com.netgrif.application.engine.elastic.service; + +import com.netgrif.application.engine.ApplicationEngine; +import com.netgrif.application.engine.TestHelper; +import com.netgrif.application.engine.elastic.domain.ElasticCaseRepository; +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseMappingService; +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseService; +import com.netgrif.application.engine.elastic.web.requestbodies.CaseSearchRequest; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.startup.ImportHelper; +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = ApplicationEngine.class) +@ActiveProfiles("test") +@ExtendWith(SpringExtension.class) +@TestPropertySource(locations = "classpath:application-test.yaml") +class ElasticCaseServiceIntegrationTest { + + private static final String FIRST_TERM = "TOTOK"; + private static final String SECOND_TERM = "Pistok"; + private static final Duration SEARCH_TIMEOUT = Duration.ofSeconds(15); + + @Autowired + private TestHelper testHelper; + + @Autowired + private ImportHelper importHelper; + + @Autowired + private IWorkflowService workflowService; + + @Autowired + private IElasticCaseService elasticCaseService; + + @Autowired + private IElasticCaseMappingService caseMappingService; + + @Autowired + private ElasticCaseRepository elasticCaseRepository; + + @Autowired + private SuperCreatorRunner superCreator; + + private PetriNet net; + + @BeforeEach + void before() { + testHelper.truncateDbs(); + net = importHelper.createNet("all_data.xml").orElseThrow(); + } + + @Test + void fullTextSearchIsCaseInsensitiveAndRequiresEveryTerm() throws InterruptedException { + Case matchingCase = createAndIndexCase(FIRST_TERM + " " + SECOND_TERM); + Case nonMatchingCase = createAndIndexCase(FIRST_TERM + " hentok"); + waitForIndexedCases(List.of(matchingCase.getStringId(), nonMatchingCase.getStringId())); + + CaseSearchRequest request = fullTextRequest( + FIRST_TERM.toLowerCase(Locale.ROOT) + " " + SECOND_TERM.toUpperCase(Locale.ROOT) + ); + Page result = waitForSearchResult(List.of(request), true, 1); + + assertEquals(1, result.getTotalElements()); + assertCaseIds(result, matchingCase); + assertEquals(1, elasticCaseService.count( + List.of(request), + superCreator.getLoggedSuper(), + Locale.ENGLISH, + true + )); + } + + @Test + void fullTextSearchNormalizesWhitespaceAndBackslashes() throws InterruptedException { + Case matchingCase = createAndIndexCase(FIRST_TERM + " " + SECOND_TERM); + waitForIndexedCases(List.of(matchingCase.getStringId())); + + CaseSearchRequest request = fullTextRequest( + " \\" + FIRST_TERM + "\\ \t\n " + SECOND_TERM + "\\ " + ); + Page result = waitForSearchResult(List.of(request), true, 1); + + assertEquals(1, result.getTotalElements()); + assertCaseIds(result, matchingCase); + } + + @Test + void fullTextSearchTreatsAsteriskAsLiteralCharacter() throws InterruptedException { + Case literalMatch = createAndIndexCase("Asterisk*Marker"); + Case wildcardLookalike = createAndIndexCase("AsteriskXMarker"); + waitForIndexedCases(List.of(literalMatch.getStringId(), wildcardLookalike.getStringId())); + + Page result = waitForSearchResult( + List.of(fullTextRequest("Asterisk*Marker")), + true, + 1 + ); + + assertEquals(1, result.getTotalElements()); + assertCaseIds(result, literalMatch); + } + + @Test + void fullTextSearchTreatsQuestionMarkAsLiteralCharacter() throws InterruptedException { + Case literalMatch = createAndIndexCase("Question?Marker"); + Case wildcardLookalike = createAndIndexCase("QuestionXMarker"); + waitForIndexedCases(List.of(literalMatch.getStringId(), wildcardLookalike.getStringId())); + + Page result = waitForSearchResult( + List.of(fullTextRequest("Question?Marker")), + true, + 1 + ); + + assertEquals(1, result.getTotalElements()); + assertCaseIds(result, literalMatch); + } + + @Test + void multipleFullTextRequestsSupportIntersectionAndUnion() throws InterruptedException { + Case matchingBoth = createAndIndexCase(FIRST_TERM + " " + SECOND_TERM); + Case matchingFirst = createAndIndexCase(FIRST_TERM + " OnlyFirst"); + Case matchingSecond = createAndIndexCase("OnlySecond " + SECOND_TERM); + waitForIndexedCases(List.of( + matchingBoth.getStringId(), + matchingFirst.getStringId(), + matchingSecond.getStringId() + )); + + List requests = List.of( + fullTextRequest(FIRST_TERM), + fullTextRequest(SECOND_TERM) + ); + + Page intersection = waitForSearchResult(requests, true, 1); + Page union = waitForSearchResult(requests, false, 3); + + assertEquals(1, intersection.getTotalElements()); + assertCaseIds(intersection, matchingBoth); + assertEquals(3, union.getTotalElements()); + assertCaseIds(union, matchingBoth, matchingFirst, matchingSecond); + assertEquals(1, elasticCaseService.count( + requests, + superCreator.getLoggedSuper(), + Locale.ENGLISH, + true + )); + assertEquals(3, elasticCaseService.count( + requests, + superCreator.getLoggedSuper(), + Locale.ENGLISH, + false + )); + } + + private Case createAndIndexCase(String title) { + Case useCase = importHelper.createCaseAsSuper(title, net); + Case savedCase = workflowService.save(useCase); + elasticCaseService.indexNow(caseMappingService.transform(savedCase)); + return savedCase; + } + + private CaseSearchRequest fullTextRequest(String fullText) { + return new CaseSearchRequest(Map.of("fullText", fullText)); + } + + private void assertCaseIds(Page result, Case... expectedCases) { + assertEquals( + List.of(expectedCases).stream() + .map(Case::getStringId) + .sorted() + .toList(), + result.getContent().stream() + .map(Case::getStringId) + .sorted() + .toList(), + "The search result contains unexpected case IDs" + ); + } + + private void waitForIndexedCases(List caseIds) throws InterruptedException { + long deadline = System.nanoTime() + SEARCH_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (caseIds.stream().allMatch(caseId -> elasticCaseRepository.findById(caseId).isPresent())) { + return; + } + Thread.sleep(100); + } + assertTrue(caseIds.stream().allMatch(caseId -> elasticCaseRepository.findById(caseId).isPresent()), + "The test cases were not indexed before the timeout"); + } + + private Page waitForSearchResult(List requests, + boolean intersection, + long expectedCount) throws InterruptedException { + long deadline = System.nanoTime() + SEARCH_TIMEOUT.toNanos(); + Page result; + do { + result = elasticCaseService.search( + requests, + superCreator.getLoggedSuper(), + PageRequest.of(0, 10), + Locale.ENGLISH, + intersection + ); + if (result.getTotalElements() == expectedCount) { + return result; + } + Thread.sleep(100); + } while (System.nanoTime() < deadline); + return result; + } +} diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java new file mode 100644 index 00000000000..d2058e15a45 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java @@ -0,0 +1,398 @@ +package com.netgrif.application.engine.elastic.service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch._types.query_dsl.WildcardQuery; +import com.netgrif.application.engine.configuration.properties.DataConfigurationProperties; +import com.netgrif.application.engine.elastic.domain.BulkOperationWrapper; +import com.netgrif.application.engine.elastic.domain.ElasticCaseRepository; +import com.netgrif.application.engine.elastic.service.executors.Executor; +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCasePrioritySearch; +import com.netgrif.application.engine.elastic.web.requestbodies.CaseSearchRequest; +import com.netgrif.application.engine.objects.auth.domain.LoggedUser; +import com.netgrif.application.engine.objects.elastic.domain.ElasticCase; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNetSearch; +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService; +import com.netgrif.application.engine.petrinet.web.responsebodies.PetriNetReference; +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate; +import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter; +import org.springframework.data.elasticsearch.core.document.Document; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ElasticCaseServiceTest { + + @Test + void buildFullTextQueryIgnoresNullAndBlankInput() { + ElasticCaseService service = service(List.of("title")); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildFullTextQuery(new CaseSearchRequest(), query); + service.buildFullTextQuery(CaseSearchRequest.builder().fullText(" \t\n ").build(), query); + + assertTrue(query.build().must().isEmpty()); + } + + @Test + void buildFullTextQueryRequiresEveryTermInAtLeastOneConfiguredField() { + ElasticCaseService service = service(List.of("title^3", "dataSet.*.fulltextValue")); + CaseSearchRequest request = new CaseSearchRequest(Map.of("fullText", "Alpha beta")); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildFullTextQuery(request, query); + + BoolQuery fullTextQuery = onlyMustClause(query.build()).bool(); + assertEquals(2, fullTextQuery.must().size()); + assertTermQuery(fullTextQuery.must().get(0).bool(), "*Alpha*", 3.0f, 1.0f); + assertTermQuery(fullTextQuery.must().get(1).bool(), "*beta*", 3.0f, 1.0f); + } + + @Test + void buildFullTextQueryEscapesLiteralWildcardCharacters() { + ElasticCaseService service = service(List.of("title")); + CaseSearchRequest request = new CaseSearchRequest(Map.of("fullText", "star* question?")); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildFullTextQuery(request, query); + + BoolQuery fullTextQuery = onlyMustClause(query.build()).bool(); + assertEquals("*star\\**", wildcard(fullTextQuery.must().get(0).bool(), 0).value()); + assertEquals("*question\\?*", wildcard(fullTextQuery.must().get(1).bool(), 0).value()); + } + + @Test + void buildFullTextQueryUsesDefaultBoostForInvalidValues() { + ElasticCaseService service = service(List.of( + "valid^2.5", + "missing", + "empty^", + "text^invalid", + "zero^0", + "negative^-4", + "nan^NaN", + "infinity^Infinity" + )); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildFullTextQuery(CaseSearchRequest.builder().fullText("term").build(), query); + + BoolQuery termQuery = onlyMustClause(query.build()).bool().must().getFirst().bool(); + assertAll( + () -> assertEquals(2.5f, wildcard(termQuery, 0).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 1).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 2).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 3).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 4).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 5).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 6).boost()), + () -> assertEquals(1.0f, wildcard(termQuery, 7).boost()) + ); + } + + @Test + void buildFullTextQueryIgnoresInputContainingOnlyEscapeCharacters() { + ElasticCaseService service = service(List.of("title")); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildFullTextQuery(CaseSearchRequest.builder().fullText("\\").build(), query); + + assertTrue(query.build().must().isEmpty()); + } + + @Test + void buildPetriNetQueryCombinesIdentifiersAndProcessIds() { + ElasticCaseService service = service(List.of("title")); + CaseSearchRequest request = CaseSearchRequest.builder() + .process(List.of( + new CaseSearchRequest.PetriNet("invoice", null), + new CaseSearchRequest.PetriNet(null, "process-id") + )) + .build(); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildPetriNetQuery(request, mock(LoggedUser.class), query); + + BoolQuery processQuery = onlyFilterClause(query.build()).bool(); + assertEquals(Set.of("processIdentifier", "processId"), processQuery.should().stream() + .map(item -> item.terms().field()) + .collect(Collectors.toSet())); + } + + @Test + void buildAuthorQueryIncludesAllProvidedAuthorAttributes() { + ElasticCaseService service = service(List.of("title")); + CaseSearchRequest request = CaseSearchRequest.builder() + .author(List.of(new CaseSearchRequest.Author("id", "Name", "username", "realm"))) + .build(); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildAuthorQuery(request, query); + + BoolQuery authorsQuery = onlyFilterClause(query.build()).bool(); + assertEquals(1, authorsQuery.should().size()); + assertEquals(5, authorsQuery.should().getFirst().bool().must().size()); + } + + @Test + void buildFieldQueriesAddsTaskRoleDataTagIdAndUriFilters() { + ElasticCaseService service = service(List.of("title")); + CaseSearchRequest request = CaseSearchRequest.builder() + .transition(List.of("transition-1")) + .role(List.of("role-1")) + .data(Map.of("plain", "value", "nested.keyword", "nested-value")) + .tags(Map.of("key", "tag-value")) + .stringId(List.of("legacy-id")) + .id(List.of("case-id")) + .uriNodeId("uri-node") + .build(); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildTaskQuery(request, query); + service.buildRoleQuery(request, query); + service.buildDataQuery(request, query); + service.buildTagsQuery(request, query); + service.buildCaseIdQuery(request, query); + service.buildUriNodeIdQuery(request, query); + + List filters = query.build().filter(); + assertEquals(6, filters.size()); + assertEquals("taskIds", filters.get(0).terms().field()); + assertEquals("enabledRoles", filters.get(1).terms().field()); + assertEquals(2, filters.get(2).bool().must().size()); + assertEquals(1, filters.get(3).bool().must().size()); + assertEquals("_id", filters.get(4).terms().field()); + assertEquals("uriNodeId", filters.get(5).term().field()); + } + + @Test + void buildStringQueryReplacesCurrentUserPlaceholder() { + ElasticCaseService service = service(List.of("title")); + ObjectId userId = new ObjectId(); + LoggedUser user = mock(LoggedUser.class); + when(user.getId()).thenReturn(userId); + BoolQuery.Builder query = new BoolQuery.Builder(); + + service.buildStringQuery(CaseSearchRequest.builder().query("author:<>").build(), query, user); + + assertEquals("author:" + userId, onlyMustClause(query.build()).queryString().query()); + } + + @Test + void buildGroupQueryHandlesEmptyAndResolvedGroups() { + Fixture fixture = fixture(List.of("title")); + LoggedUser user = mock(LoggedUser.class); + CaseSearchRequest request = CaseSearchRequest.builder().group(List.of("group-1")).build(); + PetriNetReference reference = new PetriNetReference(); + reference.setIdentifier("invoice"); + when(fixture.petriNetService().search(any(PetriNetSearch.class), same(user), any(Pageable.class), eq(Locale.ENGLISH))) + .thenReturn(Page.empty()) + .thenReturn(new PageImpl<>(List.of(reference))); + + assertTrue(fixture.service().buildGroupQuery(request, user, Locale.ENGLISH, new BoolQuery.Builder())); + + BoolQuery.Builder resolvedQuery = new BoolQuery.Builder(); + assertFalse(fixture.service().buildGroupQuery(request, user, Locale.ENGLISH, resolvedQuery)); + assertEquals("processIdentifier", onlyFilterClause(resolvedQuery.build()).terms().field()); + } + + @Test + void resolveUnmappedSortAttributesPreservesPageAndSortDirection() { + ElasticCaseService service = service(List.of("title")); + Pageable original = PageRequest.of(2, 10, Sort.by( + Sort.Order.asc("title"), + Sort.Order.desc("createdDate") + )); + + Pageable resolved = service.resolveUnmappedSortAttributes(original); + + assertEquals(2, resolved.getPageNumber()); + assertEquals(10, resolved.getPageSize()); + assertTrue(resolved.getSort().getOrderFor("title").isAscending()); + assertTrue(resolved.getSort().getOrderFor("createdDate").isDescending()); + } + + @Test + void buildQuerySupportsIntersectionUnionAndBothSortDirections() { + ElasticCaseService service = spy(service(List.of("title"))); + LoggedUser user = mock(LoggedUser.class); + List requests = List.of(new CaseSearchRequest(), new CaseSearchRequest()); + Pageable pageable = PageRequest.of(1, 5, Sort.by( + Sort.Order.asc("title"), + Sort.Order.desc("createdDate") + )); + doAnswer(ignored -> new BoolQuery.Builder()).when(service) + .buildSingleQuery(any(CaseSearchRequest.class), same(user), eq(Locale.ENGLISH)); + + assertNotNull(service.buildQuery(requests, user, pageable, Locale.ENGLISH, true)); + assertNotNull(service.buildQuery(requests, user, pageable, Locale.ENGLISH, false)); + } + + @Test + void buildSingleQueryInvokesAllEmptyFilterPaths() { + ElasticCaseService service = service(List.of("title")); + LoggedUser user = loggedUser(); + + BoolQuery query = service.buildSingleQuery(new CaseSearchRequest(), user, Locale.ENGLISH).build(); + + assertEquals(1, query.filter().size()); + } + + @Test + void searchAndCountHandleEmptyQueryAndRejectNullRequests() { + ElasticCaseService service = spy(service(List.of("title"))); + LoggedUser user = loggedUser(); + Pageable pageable = PageRequest.of(0, 10); + doReturn(null).when(service).buildQuery(anyList(), same(user), any(Pageable.class), eq(Locale.ENGLISH), eq(true)); + + Page result = service.search(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); + + assertTrue(result.isEmpty()); + assertEquals(0, service.count(List.of(new CaseSearchRequest()), user, Locale.ENGLISH, true)); + assertThrows(IllegalArgumentException.class, + () -> service.search(null, user, pageable, Locale.ENGLISH, true)); + assertThrows(IllegalArgumentException.class, + () -> service.count(null, user, Locale.ENGLISH, true)); + } + + @Test + void removeIndexIndexNowAndStopQueuesDelegateToQueueManagers() { + Fixture fixture = fixture(List.of("title")); + ElasticQueueManager indexQueue = mock(ElasticQueueManager.class); + ElasticQueueManager deleteQueue = mock(ElasticQueueManager.class); + fixture.service().caseElasticIndexQueueManager = indexQueue; + fixture.service().caseElasticDeleteQueueManager = deleteQueue; + ElasticCase useCase = mock(ElasticCase.class); + when(useCase.getId()).thenReturn("case-1"); + when(fixture.repository().findById("case-1")).thenReturn(Optional.empty()); + ElasticsearchConverter converter = mock(ElasticsearchConverter.class); + when(fixture.template().getElasticsearchConverter()).thenReturn(converter); + when(converter.mapObject(useCase)).thenReturn(Document.from(new HashMap<>())); + + fixture.service().remove("case-1"); + fixture.service().index(useCase); + fixture.service().indexNow(useCase); + ReflectionTestUtils.invokeMethod(fixture.service(), "stopQueues"); + + ArgumentCaptor deleteOperation = ArgumentCaptor.forClass(BulkOperationWrapper.class); + verify(deleteQueue).push(deleteOperation.capture()); + assertEquals("case-index", deleteOperation.getValue().getOperation().delete().index()); + assertEquals("case-1", deleteOperation.getValue().getOperation().delete().id()); + + ArgumentCaptor indexOperations = ArgumentCaptor.forClass(BulkOperationWrapper.class); + verify(indexQueue, times(2)).push(indexOperations.capture()); + assertTrue(indexOperations.getAllValues().stream() + .allMatch(operation -> "case-index".equals(operation.getOperation().index().index()))); + verify(indexQueue).shutdown(); + verify(deleteQueue).shutdown(); + } + + private void assertTermQuery(BoolQuery termQuery, String expectedValue, float firstBoost, float secondBoost) { + assertEquals("1", termQuery.minimumShouldMatch()); + assertEquals(2, termQuery.should().size()); + assertAll( + () -> assertEquals("title", wildcard(termQuery, 0).field()), + () -> assertEquals(expectedValue, wildcard(termQuery, 0).value()), + () -> assertEquals(firstBoost, wildcard(termQuery, 0).boost()), + () -> assertTrue(wildcard(termQuery, 0).caseInsensitive()), + () -> assertEquals("dataSet.*.fulltextValue", wildcard(termQuery, 1).field()), + () -> assertEquals(expectedValue, wildcard(termQuery, 1).value()), + () -> assertEquals(secondBoost, wildcard(termQuery, 1).boost()), + () -> assertTrue(wildcard(termQuery, 1).caseInsensitive()) + ); + } + + private Query onlyMustClause(BoolQuery query) { + assertEquals(1, query.must().size()); + return query.must().getFirst(); + } + + private Query onlyFilterClause(BoolQuery query) { + assertEquals(1, query.filter().size()); + return query.filter().getFirst(); + } + + private WildcardQuery wildcard(BoolQuery query, int index) { + return query.should().get(index).wildcard(); + } + + private ElasticCaseService service(List fullTextFields) { + return fixture(fullTextFields).service(); + } + + private Fixture fixture(List fullTextFields) { + IElasticCasePrioritySearch prioritySearch = mock(IElasticCasePrioritySearch.class); + when(prioritySearch.fullTextFields()).thenReturn(fullTextFields); + ElasticCaseRepository repository = mock(ElasticCaseRepository.class); + ElasticsearchTemplate template = mock(ElasticsearchTemplate.class); + IPetriNetService petriNetService = mock(IPetriNetService.class); + IWorkflowService workflowService = mock(IWorkflowService.class); + DataConfigurationProperties.ElasticsearchProperties properties = new DataConfigurationProperties.ElasticsearchProperties(); + properties.setIndex(Map.of(DataConfigurationProperties.ElasticsearchProperties.CASE_INDEX, "case-index")); + + ElasticCaseService service = new ElasticCaseService( + repository, + template, + mock(Executor.class), + properties, + petriNetService, + workflowService, + prioritySearch, + mock(ApplicationEventPublisher.class), + mock(ElasticsearchClient.class) + ); + return new Fixture(service, repository, template, petriNetService, workflowService); + } + + private LoggedUser loggedUser() { + LoggedUser user = mock(LoggedUser.class); + when(user.getId()).thenReturn(new ObjectId()); + when(user.getStringId()).thenReturn("user-id"); + when(user.getProcessRoles()).thenReturn(Set.of()); + when(user.getGroupIds()).thenReturn(Set.of()); + return user; + } + + private record Fixture(ElasticCaseService service, + ElasticCaseRepository repository, + ElasticsearchTemplate template, + IPetriNetService petriNetService, + IWorkflowService workflowService) { + } +} diff --git a/application-engine/src/test/resources/application-test.yaml b/application-engine/src/test/resources/application-test.yaml index 7bca9e86cbd..a67af46bdce 100644 --- a/application-engine/src/test/resources/application-test.yaml +++ b/application-engine/src/test/resources/application-test.yaml @@ -32,7 +32,7 @@ netgrif: server: port: 0 security: - server-patterns: /api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/v3/api-docs,/v3/api-docs/**,/swagger-ui.html,/swagger-ui/**,/api/public/**,/manage/** + server-patterns: /api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/v3/api-docs,/v3/api-docs/**,/swagger-ui.html,/swagger-ui/**,/api/public/**,/manage/health limits: login-attempts: 3 login-timeout: 3 diff --git a/application-engine/src/test/resources/petriNets/async_run.xml b/application-engine/src/test/resources/petriNets/async_run.xml new file mode 100644 index 00000000000..8e4a5d6c483 --- /dev/null +++ b/application-engine/src/test/resources/petriNets/async_run.xml @@ -0,0 +1,25 @@ + + async_run + 1.0.0 + ASR + Async Run + device_hub + true + true + false + + + async_run_create + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 382b9b7f067..14516e14414 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: # eng: -# image: netgrif/application-engine:7.0.1 +# image: netgrif/application-engine:7.0.2 # ports: # - "8080:8080" # environment: @@ -16,7 +16,8 @@ services: # NETGRIF_WORKER_NODE_NODE_TYPE: ENGINE_ROOT # netgrif.engine.data.redis.namespace: netgrif # netgrif.engine.logging.file.path: log -# netgrif.engine.management.endpoints.web.exposure.include: "*" +# netgrif.engine.management.endpoints.web.exposure.include: "health,info,metrics,prometheus,beans,env,threaddump,loggers,logfile,logfiles,caches,conditions,configprops,mappings,scheduledtasks,httpexchanges,startup,nodeinfo,elasticnodes,netgriftracing" +# netgrif.engine.management.endpoint.shutdown.enabled: 'false' # netgrif.engine.management.health.ldap.enabled: 'false' # netgrif.engine.main.allow-bean-definition-overriding: 'true' # netgrif.engine.management.health.mail.enabled: 'false' @@ -27,8 +28,8 @@ services: # netgrif.engine.management.endpoint.health.show-details: always # netgrif.engine.management.metrics.export.simple: enabled # netgrif.engine.management.endpoints.web.base-path: /manage -# netgrif.engine.security.server-patterns: /api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/v3/api-docs,/v3/api-docs/**,/swagger-ui.html,/swagger-ui/**,/api/public/**,/manage/** -# netgrif.engine.security.anonymous-exceptions: /api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/manage/** +# netgrif.engine.security.server-patterns: /api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/v3/api-docs,/v3/api-docs/**,/swagger-ui.html,/swagger-ui/**,/api/public/**,/manage/health +# netgrif.engine.security.anonymous-exceptions: /api/auth/signup,/api/auth/token/verify,/api/auth/reset,/api/auth/recover,/manage/health # depends_on: # nae-mongodb: # condition: service_started diff --git a/nae-object-library/pom.xml b/nae-object-library/pom.xml index 03c840a36f4..f9b71b3efea 100644 --- a/nae-object-library/pom.xml +++ b/nae-object-library/pom.xml @@ -7,7 +7,7 @@ com.netgrif application-engine-parent - 7.0.1 + 7.0.2 nae-object-library diff --git a/nae-spring-core-adapter/pom.xml b/nae-spring-core-adapter/pom.xml index d29da054933..162556131bc 100644 --- a/nae-spring-core-adapter/pom.xml +++ b/nae-spring-core-adapter/pom.xml @@ -7,7 +7,7 @@ com.netgrif application-engine-parent - 7.0.1 + 7.0.2 nae-spring-core-adapter diff --git a/nae-user-ce/pom.xml b/nae-user-ce/pom.xml index 9dedda37273..4823d85744d 100644 --- a/nae-user-ce/pom.xml +++ b/nae-user-ce/pom.xml @@ -6,7 +6,7 @@ com.netgrif application-engine-parent - 7.0.1 + 7.0.2 nae-user-ce diff --git a/nae-user-common/pom.xml b/nae-user-common/pom.xml index b9639bd8911..a5358188d6c 100644 --- a/nae-user-common/pom.xml +++ b/nae-user-common/pom.xml @@ -6,7 +6,7 @@ com.netgrif application-engine-parent - 7.0.1 + 7.0.2 nae-user-common diff --git a/pom.xml b/pom.xml index 01fef9a9f02..01835438832 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.netgrif application-engine-parent - 7.0.1 + 7.0.2 pom NETGRIF Application Engine parent