Merge branch 'master' of github.com:Alfresco/alfresco-community-repo into fix/ACS-12618_handle-permissions-check-to-es

This commit is contained in:
Tiago Salvado
2026-09-16 12:55:48 +01:00
65 changed files with 2992 additions and 211 deletions
+15 -1
View File
@@ -628,6 +628,18 @@ jobs:
pom-dir: tas-webdav pom-dir: tas-webdav
- test-name: "Integration TAS tests" - test-name: "Integration TAS tests"
pom-dir: tas-integration pom-dir: tas-integration
- test-name: "Elasticsearch E2E Tests - part1"
pom-dir: tas-restapi
test-profile: elasticsearch-e2e-tests-part1
compose-file: docker-compose-minimal+batch-indexing.yml
- test-name: "Elasticsearch E2E Tests - part2"
pom-dir: tas-restapi
test-profile: elasticsearch-e2e-tests-part2
compose-file: docker-compose-minimal+batch-indexing.yml
- test-name: "Elasticsearch E2E Tests - part3"
pom-dir: tas-restapi
test-profile: elasticsearch-e2e-tests-part3
compose-file: docker-compose-minimal+batch-indexing.yml
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v18.20.0 - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v18.20.0
@@ -645,10 +657,12 @@ jobs:
run: | run: |
bash ./scripts/ci/init.sh bash ./scripts/ci/init.sh
bash ./scripts/ci/build.sh bash ./scripts/ci/build.sh
- name: "Set transformers tag"
run: echo "TRANSFORMERS_TAG=$(mvn help:evaluate -Dexpression=dependency.alfresco-transform-core.version -q -DforceStdout)" >> $GITHUB_ENV
- name: "Set up the environment" - name: "Set up the environment"
timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }}
run: | run: |
${{ env.TAS_SCRIPTS }}/start-compose.sh ${{ env.TAS_ENVIRONMENT }}/docker-compose-minimal+transforms.yml ${{ env.TAS_SCRIPTS }}/start-compose.sh ${{ env.TAS_ENVIRONMENT }}/${{ matrix.compose-file || 'docker-compose-minimal+transforms.yml' }}
${{ env.TAS_SCRIPTS }}/wait-for-alfresco-start.sh "http://localhost:8082/alfresco" ${{ env.TAS_SCRIPTS }}/wait-for-alfresco-start.sh "http://localhost:8082/alfresco"
- name: "Run tests" - name: "Run tests"
id: tests id: tests
+36 -2
View File
@@ -353,6 +353,40 @@
"is_secret": false "is_secret": false
} }
], ],
"packaging/tests/environment/docker-compose-minimal+batch-indexing.yml": [
{
"type": "Secret Keyword",
"filename": "packaging/tests/environment/docker-compose-minimal+batch-indexing.yml",
"hashed_secret": "9e3d103f7aa5f4f778cf752087dfceeba15d4fef",
"is_verified": false,
"line_number": 18,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "packaging/tests/environment/docker-compose-minimal+batch-indexing.yml",
"hashed_secret": "7da287da4e071091f2ec1b3c2cfa3c3849b14163",
"is_verified": false,
"line_number": 28,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "packaging/tests/environment/docker-compose-minimal+batch-indexing.yml",
"hashed_secret": "a4a747bd4ba5e3a5049cad116881867c71fb625b",
"is_verified": false,
"line_number": 154,
"is_secret": false
},
{
"type": "Secret Keyword",
"filename": "packaging/tests/environment/docker-compose-minimal+batch-indexing.yml",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 158,
"is_secret": false
}
],
"packaging/tests/environment/docker-compose-minimal+transforms.yml": [ "packaging/tests/environment/docker-compose-minimal+transforms.yml": [
{ {
"type": "Secret Keyword", "type": "Secret Keyword",
@@ -691,7 +725,7 @@
"filename": "remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java", "filename": "remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java",
"hashed_secret": "4ffa743f28fc949c19e65ab045d7906d0609a2c2", "hashed_secret": "4ffa743f28fc949c19e65ab045d7906d0609a2c2",
"is_verified": false, "is_verified": false,
"line_number": 268, "line_number": 277,
"is_secret": false "is_secret": false
} }
], ],
@@ -1868,5 +1902,5 @@
} }
] ]
}, },
"generated_at": "2026-08-17T14:05:27Z" "generated_at": "2026-09-01T06:39:16Z"
} }
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-amps</artifactId> <artifactId>alfresco-community-repo-amps</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<modules> <modules>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-parent</artifactId> <artifactId>alfresco-governance-services-community-parent</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<modules> <modules>
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-automation-community-repo</artifactId> <artifactId>alfresco-governance-services-automation-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<build> <build>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-parent</artifactId> <artifactId>alfresco-governance-services-community-parent</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<modules> <modules>
+1 -1
View File
@@ -1,3 +1,3 @@
SOLR6_TAG=2.0.22-A.1 SOLR6_TAG=2.0.22
POSTGRES_TAG=17.9 POSTGRES_TAG=17.9
ACTIVEMQ_TAG=6.2.0-jre17-rockylinux8 ACTIVEMQ_TAG=6.2.0-jre17-rockylinux8
@@ -1,6 +1,6 @@
ARG BASE_IMAGE ARG BASE_IMAGE
# BUILD STAGE AGS # BUILD STAGE AGS
FROM debian:11-slim AS AGSBUILDER FROM debian:12-slim AS AGSBUILDER
RUN export DEBIAN_FRONTEND=noninteractive; \ RUN export DEBIAN_FRONTEND=noninteractive; \
apt-get update -qqy && apt-get -yqq install unzip && \ apt-get update -qqy && apt-get -yqq install unzip && \
@@ -8,7 +8,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-repo-parent</artifactId> <artifactId>alfresco-governance-services-community-repo-parent</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<properties> <properties>
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-repo-parent</artifactId> <artifactId>alfresco-governance-services-community-repo-parent</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<build> <build>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<modules> <modules>
+1 -1
View File
@@ -8,7 +8,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-amps</artifactId> <artifactId>alfresco-community-repo-amps</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<properties> <properties>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<dependencies> <dependencies>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<properties> <properties>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<dependencies> <dependencies>
+1 -1
View File
@@ -9,6 +9,6 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId> <artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
</project> </project>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId> <artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<properties> <properties>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<modules> <modules>
+3 -1
View File
@@ -1,3 +1,5 @@
SOLR6_TAG=2.0.22-A.1 SOLR6_TAG=2.0.22
POSTGRES_TAG=17.9 POSTGRES_TAG=17.9
ACTIVEMQ_TAG=6.2.0-jre17-rockylinux8 ACTIVEMQ_TAG=6.2.0-jre17-rockylinux8
ELASTICSEARCH_TAG=8.17.10
BATCH_INDEXING_TAG=5.9.0-A.1
@@ -0,0 +1,162 @@
# Testing/development compose for TAS tests using Elasticsearch batch-indexing
# instead of the deprecated Solr search service.
#
# Notes:
# - The 'alfresco' image is the locally built one produced by scripts/ci/build.sh
# (same convention as docker-compose-minimal+transforms.yml).
# - Solr is intentionally NOT started. The Solr endpoint properties are only
# kept for secure-comms with content transformation. The shared secret must
# match ALFRESCO_CONTENT_TRANSFORM_SHAREDSECRET on the batch-indexing service.
# - Ports mirror the minimal+transforms file so the existing TAS scripts
# (wait-for-alfresco-start.sh on http://localhost:8082/alfresco) keep working.
services:
alfresco:
image: alfresco/alfresco-community-repo-base:latest
mem_limit: 1900m
environment:
JAVA_TOOL_OPTIONS: "
-Dencryption.keystore.type=JCEKS
-Dencryption.cipherAlgorithm=DESede/CBC/PKCS5Padding
-Dencryption.keyAlgorithm=DESede
-Dencryption.keystore.location=/usr/local/tomcat/shared/classes/alfresco/extension/keystore/keystore
-Dmetadata-keystore.password=mp6yc0UD9e
-Dmetadata-keystore.aliases=metadata
-Dmetadata-keystore.metadata.password=oKIWzVdEdA
-Dmetadata-keystore.metadata.algorithm=DESede
"
JAVA_OPTS: "
-Ddb.driver=org.postgresql.Driver
-Ddb.username=alfresco
-Ddb.password=alfresco
-Ddb.url=jdbc:postgresql://postgres:5432/alfresco
-Dsolr.secureComms=secret
-Dsolr.sharedSecret=secret
-Dindex.subsystem.name=elasticsearch
-Delasticsearch.createIndexIfNotExists=true
-Delasticsearch.host=elasticsearch
-Delasticsearch.port=9200
-Dalfresco.restApi.basicAuthScheme=true
-Dalfresco.host=localhost
-Dalfresco.port=8080
-Dcsrf.filter.enabled=false
-Daos.baseUrlOverwrite=http://localhost:8080/alfresco/aos
-Dmessaging.broker.url=\"failover:(nio://activemq:61616)?timeout=3000&jms.useCompression=true\"
-Dmessaging.broker.username=admin
-Dmessaging.broker.password=admin
-Ddeployment.method=DOCKER_COMPOSE
-DlocalTransform.core-aio.url=http://transform-core-aio:8090/
-Dcors.enabled=true
-Dcors.allowed.origins=http://localhost:4200,http://localhost:8080
-XX:MinRAMPercentage=50
-XX:MaxRAMPercentage=80
"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/alfresco/api/-default-/public/alfresco/versions/1/probes/-ready-"]
interval: 30s
timeout: 10s
retries: 10
start_period: 3m
ports:
- 8082:8080
- 8000:8000
postgres:
image: library/postgres:${POSTGRES_TAG}
mem_limit: 512m
environment:
- POSTGRES_PASSWORD=alfresco
- POSTGRES_USER=alfresco
- POSTGRES_DB=alfresco
command: postgres -c max_connections=300 -c log_min_messages=LOG
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $$POSTGRES_DB -U $$POSTGRES_USER" ]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
activemq:
image: quay.io/alfresco/alfresco-activemq:${ACTIVEMQ_TAG}
mem_limit: 1g
environment:
- ACTIVEMQ_ADMIN_LOGIN=admin
- ACTIVEMQ_ADMIN_PASSWORD=admin
ports:
- 8161:8161
- 5672:5672
- 61616:61616
- 61613:61613
healthcheck:
test: [ "CMD-SHELL", "/opt/activemq/bin/activemq query --objname type=Broker,brokerName=*,service=Health | grep -q Good" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
transform-core-aio:
image: quay.io/alfresco/alfresco-transform-core-aio:${TRANSFORMERS_TAG}
mem_limit: 1536m
environment:
JAVA_OPTS: >-
-XX:MinRAMPercentage=50
-XX:MaxRAMPercentage=80
ports:
- "8090:8090"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8090/ready" ]
interval: 20s
timeout: 2s
retries: 3
start_period: 10s
depends_on:
activemq:
condition: service_healthy
elasticsearch:
image: elasticsearch:${ELASTICSEARCH_TAG}
environment:
- xpack.security.enabled=false
- discovery.type=single-node
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
cap_add:
- IPC_LOCK
ports:
- 9200:9200
- 9300:9300
healthcheck:
test: ["CMD-SHELL", "curl -s -X GET http://localhost:9200/_cluster/health?pretty | grep status | grep -q '\\(green\\|yellow\\)'"]
interval: 10s
timeout: 10s
retries: 5
start_period: 30s
batch-indexing:
image: docker.io/alfresco/alfresco-elasticsearch-batch-indexing:${BATCH_INDEXING_TAG}
mem_limit: 1g
depends_on:
alfresco:
condition: service_healthy
elasticsearch:
condition: service_healthy
transform-core-aio:
condition: service_healthy
environment:
JAVA_OPTS: "-Xms256m -Xmx768m"
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/alfresco
SPRING_DATASOURCE_USERNAME: alfresco
SPRING_DATASOURCE_PASSWORD: alfresco
SPRING_ELASTICSEARCH_REST_URIS: http://elasticsearch:9200
ALFRESCO_ACCEPTEDCONTENTMEDIATYPESCACHE_BASEURL: http://transform-core-aio:8090/transform/config
ALFRESCO_ACS_URL: http://alfresco:8080
ALFRESCO_CONTENT_TRANSFORM_SHAREDSECRET: secret
ALFRESCO_REINDEX_CONTINUOUS_POLLINGINTERVAL: 15s
ALFRESCO_REINDEX_CONTINUOUS_CATCHUPPOLLINGINTERVAL: 1s
ALFRESCO_REINDEX_CONTINUOUS_MAXWINDOW: 30m
ALFRESCO_REINDEX_CONTINUOUS_OVERLAP: 10m
+1 -1
View File
@@ -6,7 +6,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId> <artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<modules> <modules>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId> <artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<organization> <organization>
+1 -1
View File
@@ -9,7 +9,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId> <artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<developers> <developers>
+1 -1
View File
@@ -9,7 +9,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId> <artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<developers> <developers>
+15 -3
View File
@@ -8,7 +8,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId> <artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<properties> <properties>
@@ -42,9 +42,21 @@
</properties> </properties>
</profile> </profile>
<profile> <profile>
<id>elasticsearch-e2e-tests</id> <id>elasticsearch-e2e-tests-part1</id>
<properties> <properties>
<suiteXmlFile>${project.basedir}/src/test/resources/elasticsearch-e2e-suite.xml</suiteXmlFile> <suiteXmlFile>${project.basedir}/src/test/resources/test-suites/elasticsearch-e2e-part1-suite.xml</suiteXmlFile>
</properties>
</profile>
<profile>
<id>elasticsearch-e2e-tests-part2</id>
<properties>
<suiteXmlFile>${project.basedir}/src/test/resources/test-suites/elasticsearch-e2e-part2-suite.xml</suiteXmlFile>
</properties>
</profile>
<profile>
<id>elasticsearch-e2e-tests-part3</id>
<properties>
<suiteXmlFile>${project.basedir}/src/test/resources/test-suites/elasticsearch-e2e-part3-suite.xml</suiteXmlFile>
</properties> </properties>
</profile> </profile>
</profiles> </profiles>
@@ -28,60 +28,54 @@ package org.alfresco.rest.search;
import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue; import static org.testng.Assert.assertTrue;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import io.restassured.path.json.JsonPath;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import org.alfresco.rest.core.RestRequest; import org.alfresco.rest.model.RestTagModel;
import org.alfresco.utility.Utility;
import org.alfresco.utility.data.RandomData; import org.alfresco.utility.data.RandomData;
import org.alfresco.utility.model.FileModel; import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FileType; import org.alfresco.utility.model.FileType;
/** /**
* End-to-end test for the Share Document Library "tag" filter (slingshot {@code doclist} webscript, driven by {@code filters.lib.js}) running against a real search server. * End-to-end test for tag-based document filtering, verified through the public Search REST API ({@code /alfresco/api/-default-/public/search/versions/1/search}) running against a real search server.
* <p> * <p>
* clicking a tag that contains a space used to return either no documents or every document. This test tags documents through the public v1 REST API, waits for the live index to catch up, then calls the same {@code /slingshot/doclib2/doclist} endpoint the Share UI uses and asserts that the tag filter returns exactly the tagged document - for both a single-word tag and a tag containing a space. * Clicking a tag that contains a space used to return either no documents or every document. This test tags documents through the public v1 REST API (capturing each tag's category nodeRef from the response), waits for the live index to catch up, then runs an exact {@code +=cm\:taggable:"<tagNodeRef>"} membership query and asserts that the tag filter returns exactly the tagged document - for both a single-word tag and a tag containing a space. The exact-term match on the tag nodeRef is honoured by both Solr and Elasticsearch, mirroring the Share {@code filters.lib.js} tag filter.
* <p> * <p>
* The test lives in {@code org.alfresco.rest.search} so it is picked up automatically by the Elasticsearch E2E suite ({@code elasticsearch-e2e-suite.xml}), proving the fix works against an Elasticsearch server. * The test lives in {@code org.alfresco.rest.search} so it is picked up automatically by the Elasticsearch E2E suite ({@code elasticsearch-e2e-suite.xml}), proving the behaviour works against an Elasticsearch server. Using the Search API (rather than the Share {@code slingshot/doclib2/doclist} webscript) keeps the test runnable on the community-repo stack, which does not deploy the share-services module.
*/ */
@SuppressWarnings({"PMD.MethodNamingConventions", "PMD.LongVariable"}) @SuppressWarnings({"PMD.MethodNamingConventions", "PMD.LongVariable"})
public class DocumentLibraryTagFilterTest extends AbstractE2EFunctionalTest public class DocumentLibraryTagFilterTest extends AbstractE2EFunctionalTest
{ {
/** Webscript service prefix for the slingshot doclist endpoint (equivalent to {@code /alfresco/s}). */
private static final String DOCLIST_BASE_PATH = "alfresco/service/slingshot/doclib2/doclist";
/** Default Share Document Library container name. */
private static final String DOCUMENT_LIBRARY = "documentLibrary";
private String singleWordTag;
private String spaceTag;
private FileModel singleWordTaggedFile; private FileModel singleWordTaggedFile;
private FileModel spaceTaggedFile; private FileModel spaceTaggedFile;
private String singleWordTagNodeRef;
private String spaceTagNodeRef;
@BeforeClass(alwaysRun = true) @BeforeClass(alwaysRun = true)
public void dataPreparation() public void dataPreparation()
{ {
// Unique suffix keeps the tags private to this test run (the tag filter is repo-wide, not site-scoped). // Unique suffix keeps the tags private to this test run (the tag filter is repo-wide, not site-scoped).
String unique = RandomData.getRandomName("Tag").toLowerCase(); String unique = RandomData.getRandomName("Tag").toLowerCase();
singleWordTag = "single" + unique; String singleWordTag = "single" + unique;
spaceTag = "long " + unique; // contains a space - the scenario that used to fail String spaceTag = "long " + unique; // contains a space - the scenario that used to fail
singleWordTaggedFile = createTaggedFile(singleWordTag); singleWordTaggedFile = createFile();
spaceTaggedFile = createTaggedFile(spaceTag); singleWordTagNodeRef = tagFileAndGetTagNodeRef(singleWordTaggedFile, singleWordTag);
// Wait until both tags resolve through the doclist endpoint (category node + cm:taggable both indexed). spaceTaggedFile = createFile();
assertTrue(waitForTagFilter(singleWordTag, singleWordTaggedFile.getName()), spaceTagNodeRef = tagFileAndGetTagNodeRef(spaceTaggedFile, spaceTag);
// Wait until both tags resolve through the Search API (cm:taggable indexed for each document).
assertTrue(waitForTagFilter(singleWordTagNodeRef, singleWordTaggedFile.getName()),
"Single-word tag was not indexed/searchable in time: " + singleWordTag); "Single-word tag was not indexed/searchable in time: " + singleWordTag);
assertTrue(waitForTagFilter(spaceTag, spaceTaggedFile.getName()), assertTrue(waitForTagFilter(spaceTagNodeRef, spaceTaggedFile.getName()),
"Space-containing tag was not indexed/searchable in time: " + spaceTag); "Space-containing tag was not indexed/searchable in time: " + spaceTag);
} }
@@ -89,78 +83,78 @@ public class DocumentLibraryTagFilterTest extends AbstractE2EFunctionalTest
@Test @Test
public void tagFilterWithSpaceInTagNameReturnsOnlyTheTaggedDocument() public void tagFilterWithSpaceInTagNameReturnsOnlyTheTaggedDocument()
{ {
assertTagFilterReturnsExactly(spaceTag, spaceTaggedFile.getName(), singleWordTaggedFile.getName()); assertTagFilterReturnsExactly(spaceTagNodeRef, spaceTaggedFile.getName(), singleWordTaggedFile.getName());
} }
/** Regression guard: single-word tags keep working exactly as before. */ /** Regression guard: single-word tags keep working exactly as before. */
@Test @Test
public void tagFilterWithSingleWordTagReturnsOnlyTheTaggedDocument() public void tagFilterWithSingleWordTagReturnsOnlyTheTaggedDocument()
{ {
assertTagFilterReturnsExactly(singleWordTag, singleWordTaggedFile.getName(), spaceTaggedFile.getName()); assertTagFilterReturnsExactly(singleWordTagNodeRef, singleWordTaggedFile.getName(), spaceTaggedFile.getName());
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** Creates a text document in the test site's document library and tags it via the public v1 REST API. */ /** Creates a text document in the test site's document library. */
private FileModel createTaggedFile(String tag) private FileModel createFile()
{ {
FileModel file = FileModel.getRandomFileModel(FileType.TEXT_PLAIN, "MNT-25799 tag filter test content"); FileModel file = FileModel.getRandomFileModel(FileType.TEXT_PLAIN, "MNT-25799 tag filter test content");
dataContent.usingUser(testUser).usingSite(testSite).createContent(file); dataContent.usingUser(testUser).usingSite(testSite).createContent(file);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(file).addTag(tag);
restClient.assertStatusCodeIs(HttpStatus.CREATED);
return file; return file;
} }
/** Runs the tag filter and asserts it returns exactly the expected file and never the other (unrelated) file. */ /** Tags the file via the public v1 REST API and returns the created tag category nodeRef. */
private void assertTagFilterReturnsExactly(String tag, String expectedFileName, String excludedFileName) private String tagFileAndGetTagNodeRef(FileModel file, String tag)
{ {
JsonPath json = tagFilter(tag); RestTagModel tagModel = restClient.authenticateUser(testUser).withCoreAPI().usingResource(file).addTag(tag);
restClient.assertStatusCodeIs(HttpStatus.OK); restClient.assertStatusCodeIs(HttpStatus.CREATED);
return "workspace://SpacesStore/" + tagModel.getId();
List<String> fileNames = json.getList("items.location.file");
assertNotNull(fileNames, "Doclist response did not contain an items list for tag: " + tag);
assertTrue(fileNames.contains(expectedFileName),
"Tag filter '" + tag + "' did not return the tagged document '" + expectedFileName + "'. Got: " + fileNames);
assertFalse(fileNames.contains(excludedFileName),
"Tag filter '" + tag + "' incorrectly returned an unrelated document '" + excludedFileName + "'. Got: " + fileNames);
assertEquals(json.getInt("totalRecords"), 1,
"Tag filter '" + tag + "' returned an unexpected number of documents. Got: " + fileNames);
} }
/** Polls the doclist tag filter until {@code expectedFileName} appears or the retry budget is exhausted. */ /** Runs the tag filter and asserts it returns exactly the expected file and never the other (unrelated) file. */
private boolean waitForTagFilter(String tag, String expectedFileName) private void assertTagFilterReturnsExactly(String tagNodeRef, String expectedFileName, String excludedFileName)
{ {
for (int attempt = 0; attempt < SEARCH_MAX_ATTEMPTS; attempt++) SearchResponse response = tagFilter(tagNodeRef);
{ restClient.assertStatusCodeIs(HttpStatus.OK);
JsonPath json = tagFilter(tag);
if (String.valueOf(HttpStatus.OK.value()).equals(restClient.getStatusCode())) List<String> fileNames = resultFileNames(response);
{ assertTrue(fileNames.contains(expectedFileName),
List<String> fileNames = json.getList("items.location.file"); "Tag filter '" + tagNodeRef + "' did not return the tagged document '" + expectedFileName + "'. Got: " + fileNames);
if (fileNames != null && fileNames.contains(expectedFileName)) assertFalse(fileNames.contains(excludedFileName),
{ "Tag filter '" + tagNodeRef + "' incorrectly returned an unrelated document '" + excludedFileName + "'. Got: " + fileNames);
return true; assertEquals(fileNames.size(), 1,
} "Tag filter '" + tagNodeRef + "' returned an unexpected number of documents. Got: " + fileNames);
} }
Utility.waitToLoopTime(properties.getSolrWaitTimeInSeconds(),
"Waiting for tag to be indexed. Attempt: " + (attempt + 1)); /** Polls the Search API tag filter until {@code expectedFileName} appears or the retry budget is exhausted. */
} private boolean waitForTagFilter(String tagNodeRef, String expectedFileName)
return false; {
return isContentInSearchResults(tagQuery(tagNodeRef), expectedFileName, true);
}
/** Runs the exact {@code +=cm\:taggable} membership search for the given tag nodeRef as {@link #testUser}. */
private SearchResponse tagFilter(String tagNodeRef)
{
return query(createQuery(tagQuery(tagNodeRef)));
} }
/** /**
* Calls the slingshot doclist webscript with the tag filter, as the Share UI does: {@code GET /alfresco/s/slingshot/doclib2/doclist/all/site/{site}/documentLibrary?filter=tag&filterData=<tag>}. * Builds the AFTS query that matches documents carrying the given tag.
* <p>
* MNT-25799: uses an exact ({@code =}) membership match on the tag category nodeRef, mirroring the Share {@code filters.lib.js} tag filter. This form is honoured by both Solr and Elasticsearch and returns exactly the tagged document(s) - including for tags containing spaces, since the match is on the nodeRef rather than a tokenised tag phrase.
*/ */
private JsonPath tagFilter(String tag) private String tagQuery(String tagNodeRef)
{ {
restClient.authenticateUser(testUser); return "+=cm\\:taggable:\"" + tagNodeRef + "\"";
restClient.configureRequestSpec().setBasePath(DOCLIST_BASE_PATH); }
RestRequest request = RestRequest.simpleRequest(HttpMethod.GET, /** Extracts the {@code cm:name} of every document returned by a search response. */
"all/site/{site}/{container}?filter=tag&filterData={filterData}", private List<String> resultFileNames(SearchResponse response)
testSite.getId(), DOCUMENT_LIBRARY, tag); {
return restClient.process(request).getResponse().jsonPath(); return response.getEntries().stream()
.map(entry -> entry.getModel().getName())
.collect(Collectors.toList());
} }
} }
@@ -0,0 +1,109 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for advanced AFTS query operators on Elasticsearch.
*/
public class SearchAdvancedQueryOperatorsTest extends AbstractSearchServicesE2ETest
{
private FileModel fileWithoutTitle;
private FileModel proximityFile;
private static final String UNIQUE_PREFIX = "acsadvops";
// Unique fake tokens — avoid tokenization surprises and cross-run pollution.
private static final String TOKEN_A = "acsadvproxaaa";
private static final String TOKEN_B = "acsadvproxbbb";
private static final String TOKEN_C = "acsadvproxccc";
private static final String TOKEN_D = "acsadvproxddd";
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
FolderModel folder = dataContent.usingUser(testUser).usingSite(testSite)
.createFolderCmisApi(UNIQUE_PREFIX + "-folder");
// File created without setting cm:title — used for ISUNSET.
fileWithoutTitle = new FileModel(UNIQUE_PREFIX + "-file-without-title.txt");
fileWithoutTitle.setContent("Body of the file without a title");
dataContent.usingUser(testUser).usingResource(folder).createContent(fileWithoutTitle);
// Content: 4 unique tokens at known positions A=0, B=1, C=2, D=3.
// Between A and D there are exactly 2 words (B, C), so proximity *(2) must match.
proximityFile = new FileModel(UNIQUE_PREFIX + "-proximity-file.txt");
proximityFile.setContent(TOKEN_A + " " + TOKEN_B + " " + TOKEN_C + " " + TOKEN_D);
dataContent.usingUser(testUser).usingResource(folder).createContent(proximityFile);
waitForMetadataIndexing(fileWithoutTitle.getName(), true);
waitForMetadataIndexing(proximityFile.getName(), true);
// Poll until the file's content is truly indexed on ES.
Assert.assertTrue(isContentInSearchResults("cm:content:'" + TOKEN_A + "' AND cm:name:'" + proximityFile.getName() + "'",
proximityFile.getName(), true), "Setup: proximity file's content should be indexed on ES before running tests");
}
/**
* ISUNSET:"cm:title" must return the file whose cm:title was never set.
*/
@Test(priority = 1)
public void testIsUnsetOperator()
{
String query = "ISUNSET:\"cm:title\" AND cm:name:'" + fileWithoutTitle.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected file without a title to be findable via ISUNSET on cm:title");
Assert.assertTrue(isContentInSearchResponse(response, fileWithoutTitle.getName()),
"Expected " + fileWithoutTitle.getName() + " in the ISUNSET results");
}
/**
* AFTS proximity syntax: 'wordA *(N) wordB' matches when at most N words separate the two.TOKEN_A (position 0) and TOKEN_D (position 3) have exactly 2 words between them (TOKEN_B, TOKEN_C), so *(2) must match.
*/
@Test(priority = 2)
public void testProximitySearchUsingAftsSyntax()
{
String query = "cm:content:(" + TOKEN_A + " *(2) " + TOKEN_D + ") AND cm:name:'" +
proximityFile.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected AFTS proximity '" + TOKEN_A + " *(2) " + TOKEN_D +
"' to find the file (2 words between A and D in the content)");
Assert.assertTrue(isContentInSearchResponse(response, proximityFile.getName()),
"Expected " + proximityFile.getName() + " in the proximity results");
}
}
@@ -0,0 +1,320 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for category-based search scenarios on Elasticsearch.
*/
public class SearchCategoriesTest extends AbstractSearchServicesE2ETest
{
private FolderModel folder;
private FileModel fileWithPrimaryCategory;
private FileModel anotherFileWithPrimaryCategory;
private FileModel fileWithCategoryAndTag;
private FileModel fileWithBothCategories;
private FileModel fileForCategoryRemoval;
private FileModel fileForLateAssignment;
private FileModel isolationFileA;
private FileModel isolationFileB;
private String primaryCategoryNodeRef;
private String secondaryCategoryNodeRef;
private static final String COMBINED_TAG = "acsmigrationcombinedtag";
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
folder = dataContent.usingUser(testUser).usingSite(testSite).createFolderCmisApi("categories-folder");
primaryCategoryNodeRef = lookupCategoryRefByName("Software Document Classification");
secondaryCategoryNodeRef = lookupCategoryRefByName("Regions");
if (primaryCategoryNodeRef == null || secondaryCategoryNodeRef == null)
{
SearchResponse anyCats = queryAsUser(dataUser.getAdminUser(), "TYPE:'cm:category'");
Assert.assertTrue(anyCats.getPagination().getCount() >= 2,
"Test setup requires at least two cm:category nodes in the repository");
if (primaryCategoryNodeRef == null)
{
primaryCategoryNodeRef = "workspace://SpacesStore/" + anyCats.getEntries().get(0).getModel().getId();
}
if (secondaryCategoryNodeRef == null)
{
secondaryCategoryNodeRef = "workspace://SpacesStore/" + anyCats.getEntries().get(1).getModel().getId();
}
}
fileWithPrimaryCategory = createFileClassifiedInto("file-with-primary-category.txt",
"File classified into primary category", primaryCategoryNodeRef);
anotherFileWithPrimaryCategory = createFileClassifiedInto("another-file-with-primary-category.txt",
"Second file classified into primary category", primaryCategoryNodeRef);
fileWithCategoryAndTag = createFileClassifiedInto("file-with-category-and-tag.txt",
"File with both a category and a tag", primaryCategoryNodeRef);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithCategoryAndTag).addTag(COMBINED_TAG);
fileWithBothCategories = createFileClassifiedInto("file-with-both-categories.txt",
"File classified into two categories", primaryCategoryNodeRef, secondaryCategoryNodeRef);
fileForCategoryRemoval = createFileClassifiedInto("file-for-category-removal.txt",
"File whose category will be removed to verify de-indexing", primaryCategoryNodeRef);
fileForLateAssignment = new FileModel("file-for-late-category-assignment.txt");
fileForLateAssignment.setContent("File that receives a category after creation");
dataContent.usingUser(testUser).usingResource(folder).createContent(fileForLateAssignment);
isolationFileA = createFileClassifiedInto("isolation-file-a.txt",
"First isolation test file (category will be removed)", secondaryCategoryNodeRef);
isolationFileB = createFileClassifiedInto("isolation-file-b.txt",
"Second isolation test file (category must stay)", secondaryCategoryNodeRef);
waitForMetadataIndexing(fileWithPrimaryCategory.getName(), true);
waitForMetadataIndexing(anotherFileWithPrimaryCategory.getName(), true);
waitForMetadataIndexing(fileWithCategoryAndTag.getName(), true);
waitForMetadataIndexing(fileWithBothCategories.getName(), true);
waitForMetadataIndexing(fileForCategoryRemoval.getName(), true);
waitForMetadataIndexing(fileForLateAssignment.getName(), true);
waitForMetadataIndexing(isolationFileA.getName(), true);
waitForMetadataIndexing(isolationFileB.getName(), true);
Assert.assertTrue(isContentInSearchResults("cm:categories:\"" + primaryCategoryNodeRef + "\"", fileWithPrimaryCategory.getName(), true),
"Setup: primary-category association should be searchable after batch-indexing catches up");
Assert.assertTrue(isContentInSearchResults("cm:categories:\"" + secondaryCategoryNodeRef + "\"", isolationFileA.getName(), true),
"Setup: secondary-category association should be searchable after batch-indexing catches up");
Assert.assertTrue(isContentInSearchResults("TAG:'" + COMBINED_TAG + "'", fileWithCategoryAndTag.getName(), true),
"Setup: combined tag should be searchable after batch-indexing catches up");
}
private String lookupCategoryRefByName(String name)
{
SearchResponse response = queryAsUser(dataUser.getAdminUser(),
"TYPE:'cm:category' AND cm:name:'" + name + "'");
if (response.getPagination().getCount() >= 1)
{
return "workspace://SpacesStore/" + response.getEntries().getFirst().getModel().getId();
}
return null;
}
private FileModel createFileClassifiedInto(String name, String content, String... categoryRefs)
{
FileModel file = new FileModel(name);
file.setContent(content);
dataContent.usingUser(testUser).usingResource(folder).createContent(file);
jakarta.json.JsonArrayBuilder catArray = Json.createArrayBuilder();
for (String ref : categoryRefs)
{
catArray.add(ref);
}
JsonObject body = Json.createObjectBuilder()
.add("aspectNames", Json.createArrayBuilder().add("cm:generalclassifiable"))
.add("properties", Json.createObjectBuilder().add("cm:categories", catArray))
.build();
restClient.authenticateUser(testUser).withCoreAPI().usingNode(file).updateNode(body.toString());
return file;
}
@Test(priority = 1)
public void testSearchByGeneralClassifiableAspect()
{
String query = "ASPECT:'cm:generalclassifiable' AND cm:name:'" + fileWithPrimaryCategory.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected file with the cm:generalclassifiable aspect to be findable");
Assert.assertTrue(isContentInSearchResponse(response, fileWithPrimaryCategory.getName()),
"Expected " + fileWithPrimaryCategory.getName() + " in the aspect query results");
}
@Test(priority = 2)
public void testSearchByCategoryProperty()
{
String query = "cm:categories:\"" + primaryCategoryNodeRef + "\" AND cm:name:'" +
fileWithPrimaryCategory.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected file classified into the category to be findable via cm:categories property");
Assert.assertTrue(isContentInSearchResponse(response, fileWithPrimaryCategory.getName()),
"Expected " + fileWithPrimaryCategory.getName() + " in the cm:categories query results");
}
@Test(priority = 3)
public void testCategoryAndTagCombined()
{
String query = "ASPECT:'cm:generalclassifiable' AND TAG:'" + COMBINED_TAG +
"' AND cm:name:'" + fileWithCategoryAndTag.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected file with both a category and the combined tag to be findable via combined query");
Assert.assertTrue(isContentInSearchResponse(response, fileWithCategoryAndTag.getName()),
"Expected " + fileWithCategoryAndTag.getName() + " in the category+tag query results");
}
@Test(priority = 4)
public void testMultipleFilesInSameCategory()
{
String pathClause = "PATH:\"/app:company_home/st:sites/cm:" + testSite.getTitle() + "/cm:documentLibrary//*\"";
String query = "cm:categories:\"" + primaryCategoryNodeRef + "\" AND " + pathClause;
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 2,
"Expected at least two files in this site classified into the primary category");
Assert.assertTrue(isContentInSearchResponse(response, fileWithPrimaryCategory.getName()),
"Expected " + fileWithPrimaryCategory.getName() + " among the primary-category files");
Assert.assertTrue(isContentInSearchResponse(response, anotherFileWithPrimaryCategory.getName()),
"Expected " + anotherFileWithPrimaryCategory.getName() + " among the primary-category files");
}
@Test(priority = 5)
public void testFileInMultipleCategoriesFoundByEach()
{
String queryPrimary = "cm:categories:\"" + primaryCategoryNodeRef + "\" AND cm:name:'" +
fileWithBothCategories.getName() + "'";
SearchResponse fromPrimary = queryAsUser(testUser, queryPrimary);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(fromPrimary.getPagination().getCount() >= 1,
"Expected multi-category file to be findable via the primary category");
Assert.assertTrue(isContentInSearchResponse(fromPrimary, fileWithBothCategories.getName()),
"Expected " + fileWithBothCategories.getName() + " when querying the primary category");
String querySecondary = "cm:categories:\"" + secondaryCategoryNodeRef + "\" AND cm:name:'" +
fileWithBothCategories.getName() + "'";
SearchResponse fromSecondary = queryAsUser(testUser, querySecondary);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(fromSecondary.getPagination().getCount() >= 1,
"Expected multi-category file to be findable via the secondary category");
Assert.assertTrue(isContentInSearchResponse(fromSecondary, fileWithBothCategories.getName()),
"Expected " + fileWithBothCategories.getName() + " when querying the secondary category");
}
@Test(priority = 6)
public void testCategoryRemovalUpdatesIndex()
{
String queryBefore = "cm:categories:\"" + primaryCategoryNodeRef + "\" AND cm:name:'" +
fileForCategoryRemoval.getName() + "'";
SearchResponse before = queryAsUser(testUser, queryBefore);
Assert.assertTrue(before.getPagination().getCount() >= 1,
"File should be findable via its category before removal");
Assert.assertTrue(isContentInSearchResponse(before, fileForCategoryRemoval.getName()),
"Expected " + fileForCategoryRemoval.getName() + " to be present before category removal");
JsonObject body = Json.createObjectBuilder()
.add("properties", Json.createObjectBuilder()
.add("cm:categories", Json.createArrayBuilder()))
.build();
restClient.authenticateUser(testUser).withCoreAPI().usingNode(fileForCategoryRemoval).updateNode(body.toString());
Assert.assertTrue(isContentInSearchResults(queryBefore, fileForCategoryRemoval.getName(), false),
"File should NOT be findable via its category after removal");
}
@Test(priority = 7)
public void testAspectRestrictsSearchToClassifiedFiles()
{
// fileForLateAssignment has no aspect yet. The cm:name query tokens ('late', 'assignment')
// don't appear in any classified file, so a match here would indicate an ES over-match bug.
String query = "ASPECT:'cm:generalclassifiable' AND cm:name:'" + fileForLateAssignment.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
boolean targetFound = response.getEntries().stream()
.anyMatch(e -> e.getModel().getName().equals(fileForLateAssignment.getName()));
Assert.assertFalse(targetFound,
"Expected uncategorised file to NOT match the cm:generalclassifiable aspect query at this point");
}
@Test(priority = 8)
public void testCategoryAddedToExistingFileGetsIndexed()
{
String categoryQuery = "cm:categories:\"" + primaryCategoryNodeRef + "\" AND cm:name:'" +
fileForLateAssignment.getName() + "'";
SearchResponse before = queryAsUser(testUser, categoryQuery);
boolean targetFoundBefore = before.getEntries().stream()
.anyMatch(e -> e.getModel().getName().equals(fileForLateAssignment.getName()));
Assert.assertFalse(targetFoundBefore,
"File should not be findable via the category before assignment");
JsonObject body = Json.createObjectBuilder()
.add("aspectNames", Json.createArrayBuilder().add("cm:generalclassifiable"))
.add("properties", Json.createObjectBuilder()
.add("cm:categories", Json.createArrayBuilder().add(primaryCategoryNodeRef)))
.build();
restClient.authenticateUser(testUser).withCoreAPI().usingNode(fileForLateAssignment).updateNode(body.toString());
Assert.assertTrue(isContentInSearchResults(categoryQuery, fileForLateAssignment.getName(), true),
"File should be findable via the category after assignment");
}
@Test(priority = 9)
public void testCategoryRemovalOnlyAffectsTargetedFile()
{
String secondaryPathClause = "PATH:\"/app:company_home/st:sites/cm:" + testSite.getTitle() +
"/cm:documentLibrary//*\"";
String queryBoth = "cm:categories:\"" + secondaryCategoryNodeRef + "\" AND " + secondaryPathClause;
SearchResponse both = queryAsUser(testUser, queryBoth);
Assert.assertTrue(both.getPagination().getCount() >= 2,
"Both isolation files should initially be findable via the secondary category");
Assert.assertTrue(isContentInSearchResponse(both, isolationFileA.getName()),
"Expected " + isolationFileA.getName() + " to be present before removal");
Assert.assertTrue(isContentInSearchResponse(both, isolationFileB.getName()),
"Expected " + isolationFileB.getName() + " to be present before removal");
JsonObject removeBody = Json.createObjectBuilder()
.add("properties", Json.createObjectBuilder()
.add("cm:categories", Json.createArrayBuilder()))
.build();
restClient.authenticateUser(testUser).withCoreAPI().usingNode(isolationFileA).updateNode(removeBody.toString());
String queryA = "cm:categories:\"" + secondaryCategoryNodeRef + "\" AND cm:name:'" + isolationFileA.getName() + "'";
Assert.assertTrue(isContentInSearchResults(queryA, isolationFileA.getName(), false),
"isolationFileA should no longer be findable via the secondary category after removal");
String queryB = "cm:categories:\"" + secondaryCategoryNodeRef + "\" AND cm:name:'" + isolationFileB.getName() + "'";
SearchResponse resultB = queryAsUser(testUser, queryB);
boolean targetBFound = resultB.getEntries().stream()
.anyMatch(e -> e.getModel().getName().equals(isolationFileB.getName()));
Assert.assertTrue(targetBFound,
"isolationFileB should still be findable via the secondary category — removal must not affect siblings");
}
}
@@ -0,0 +1,201 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for CMIS-language queries on Elasticsearch.
*/
public class SearchCmisQueriesTest extends AbstractSearchServicesE2ETest
{
private FolderModel folder;
private FileModel invoice;
private FileModel report;
private FileModel memo;
private static final String UNIQUE_PREFIX = "acscmismig";
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
folder = dataContent.usingUser(testUser).usingSite(testSite).createFolderCmisApi(UNIQUE_PREFIX + "-folder");
invoice = new FileModel(UNIQUE_PREFIX + "-invoice-january.txt");
invoice.setContent("Invoice content covering multiple line items");
dataContent.usingUser(testUser).usingResource(folder).createContent(invoice);
report = new FileModel(UNIQUE_PREFIX + "-report-quarterly.txt");
report.setContent("Quarterly report of activities");
dataContent.usingUser(testUser).usingResource(folder).createContent(report);
memo = new FileModel(UNIQUE_PREFIX + "-memo-internal.txt");
memo.setContent("Internal memo for staff");
dataContent.usingUser(testUser).usingResource(folder).createContent(memo);
waitForContentIndexing(invoice.getContent(), true);
waitForContentIndexing(report.getContent(), true);
waitForContentIndexing(memo.getContent(), true);
}
private SearchResponse runCmisQuery(String cmisQuery)
{
SearchRequest searchRequest = new SearchRequest();
RestRequestQueryModel queryModel = new RestRequestQueryModel();
queryModel.setQuery(cmisQuery);
queryModel.setLanguage(SearchLanguage.CMIS.toString());
searchRequest.setQuery(queryModel);
return restClient.authenticateUser(testUser).withSearchAPI().search(searchRequest);
}
@Test(priority = 1)
public void testCmisSelectWithLike()
{
String cmisQuery = "SELECT * FROM cmis:document WHERE cmis:name LIKE '" + UNIQUE_PREFIX + "-invoice%'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected exactly one invoice file with the CMIS LIKE query");
Assert.assertTrue(isContentInSearchResponse(response, invoice.getName()),
"Expected the returned entry to be " + invoice.getName());
}
@Test(priority = 2)
public void testCmisWithMultipleWhereClauses()
{
String cmisQuery = "SELECT * FROM cmis:document WHERE cmis:name LIKE '" + UNIQUE_PREFIX +
"%' AND cmis:name LIKE '%report%'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected exactly the report file matching both LIKE conditions");
Assert.assertTrue(isContentInSearchResponse(response, report.getName()),
"Expected the returned entry to be " + report.getName());
}
@Test(priority = 3)
public void testCmisFullTextWithMetadata()
{
String cmisQuery = "SELECT * FROM cmis:document WHERE CONTAINS('quarterly') AND cmis:name LIKE '" +
UNIQUE_PREFIX + "%'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected at least one file matching CONTAINS + cmis:name filter");
Assert.assertTrue(isContentInSearchResponse(response, report.getName()),
"Expected " + report.getName() + " in the CONTAINS + metadata results");
}
@Test(priority = 4)
public void testCmisExactEqualityMatch()
{
String cmisQuery = "SELECT * FROM cmis:document WHERE cmis:name = '" + memo.getName() + "'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected exact-equality CMIS query to return the specific file");
Assert.assertTrue(isContentInSearchResponse(response, memo.getName()),
"Expected the returned entry to be " + memo.getName());
}
@Test(priority = 5)
public void testCmisWithOrClause()
{
String cmisQuery = "SELECT * FROM cmis:document WHERE cmis:name = '" + invoice.getName() +
"' OR cmis:name = '" + report.getName() + "'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 2,
"Expected CMIS OR to return exactly two matching files");
Assert.assertTrue(isContentInSearchResponse(response, invoice.getName()),
"Expected " + invoice.getName() + " in the CMIS OR results");
Assert.assertTrue(isContentInSearchResponse(response, report.getName()),
"Expected " + report.getName() + " in the CMIS OR results");
}
@Test(priority = 6)
public void testCmisSelectFromCmisFolder()
{
String cmisQuery = "SELECT * FROM cmis:folder WHERE cmis:name = '" + folder.getName() + "'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected CMIS query on cmis:folder type to return the folder");
Assert.assertTrue(isContentInSearchResponse(response, folder.getName()),
"Expected the returned entry to be " + folder.getName());
}
@Test(priority = 7)
public void testCmisWithOrderBy()
{
String cmisQuery = "SELECT * FROM cmis:document WHERE cmis:name LIKE '" + UNIQUE_PREFIX +
"%' ORDER BY cmis:name ASC";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 3,
"Expected ORDER BY CMIS query to return the prefixed files");
Assert.assertTrue(isContentInSearchResponse(response, invoice.getName()),
"Expected " + invoice.getName() + " in the ORDER BY results");
Assert.assertTrue(isContentInSearchResponse(response, memo.getName()),
"Expected " + memo.getName() + " in the ORDER BY results");
Assert.assertTrue(isContentInSearchResponse(response, report.getName()),
"Expected " + report.getName() + " in the ORDER BY results");
String firstName = response.getEntries().getFirst().getModel().getName();
Assert.assertEquals(firstName, invoice.getName(),
"Expected the alphabetically first file (invoice) to be returned first");
}
@Test(priority = 8)
public void testCmisWithNotClause()
{
// Select all our test files but exclude anything matching '%invoice%'.
// Should return the report and memo files, but NOT the invoice.
String cmisQuery = "SELECT * FROM cmis:document WHERE cmis:name LIKE '" + UNIQUE_PREFIX +
"%' AND cmis:name NOT LIKE '%invoice%'";
SearchResponse response = runCmisQuery(cmisQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 2,
"Expected NOT LIKE to exclude the invoice file and return exactly the report and memo files");
Assert.assertTrue(isContentInSearchResponse(response, report.getName()),
"Expected " + report.getName() + " in the NOT LIKE results");
Assert.assertTrue(isContentInSearchResponse(response, memo.getName()),
"Expected " + memo.getName() + " in the NOT LIKE results");
// Verify the invoice is truly excluded (guards against ES translator bug where NOT is ignored).
boolean invoiceInResults = response.getEntries().stream()
.anyMatch(e -> e.getModel().getName().equals(invoice.getName()));
Assert.assertFalse(invoiceInResults,
"Expected the invoice file to be excluded by NOT LIKE '%invoice%'");
}
}
@@ -0,0 +1,228 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.constants.UserRole;
import org.alfresco.utility.data.DataGroup;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FileType;
import org.alfresco.utility.model.FolderModel;
import org.alfresco.utility.model.GroupModel;
import org.alfresco.utility.model.UserModel;
/**
* Migration test class for complex ACL structures on Elasticsearch.
*/
public class SearchComplexAclTest extends AbstractSearchServicesE2ETest
{
@Autowired
protected DataGroup dataGroup;
private UserModel groupUser;
private UserModel secondGroupUser;
private UserModel individualUser;
private UserModel outsiderUser;
private FolderModel folderA;
private FileModel groupProtectedFile;
private FileModel mixedPermsFile;
private FileModel inheritedProtectedFile;
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
groupUser = dataUser.createRandomTestUser("GroupMemberUser");
secondGroupUser = dataUser.createRandomTestUser("SecondGroupMemberUser");
individualUser = dataUser.createRandomTestUser("IndividualUser");
outsiderUser = dataUser.createRandomTestUser("OutsiderUser");
GroupModel authorizedGroup = dataGroup.createRandomGroup();
dataGroup.addListOfUsersToGroup(authorizedGroup, groupUser);
dataGroup.addListOfUsersToGroup(authorizedGroup, secondGroupUser);
dataUser.addUserToSite(groupUser, testSite, UserRole.SiteContributor);
dataUser.addUserToSite(secondGroupUser, testSite, UserRole.SiteContributor);
dataUser.addUserToSite(individualUser, testSite, UserRole.SiteContributor);
dataUser.addUserToSite(outsiderUser, testSite, UserRole.SiteContributor);
folderA = dataContent.usingUser(testUser).usingSite(testSite).createFolderCmisApi("acl-folder-a");
FolderModel folderB = dataContent.usingUser(testUser).usingSite(testSite).createFolderCmisApi("acl-folder-b");
groupProtectedFile = new FileModel("group-protected-file.txt", FileType.TEXT_PLAIN, "Group ACL protected");
mixedPermsFile = new FileModel("mixed-perms-file.txt", FileType.TEXT_PLAIN, "Mixed ALLOW/DENY ACL");
inheritedProtectedFile = new FileModel("inherited-protected-file.txt", FileType.TEXT_PLAIN, "Inherits parent ACL");
dataContent.usingUser(testUser).usingResource(folderA).createContent(groupProtectedFile);
dataContent.usingUser(testUser).usingResource(folderB).createContent(mixedPermsFile);
JsonObject folderAPerm = Json.createObjectBuilder()
.add("permissions", Json.createObjectBuilder()
.add("isInheritanceEnabled", false)
.add("locallySet", Json.createObjectBuilder()
.add("authorityId", "GROUP_" + authorizedGroup.getGroupIdentifier())
.add("name", "SiteContributor")
.add("accessStatus", "ALLOWED")))
.build();
restClient.authenticateUser(testUser).withCoreAPI().usingNode(folderA).updateNode(folderAPerm.toString());
restClient.authenticateUser(testUser).withCoreAPI().usingNode(groupProtectedFile).updateNode(folderAPerm.toString());
dataContent.usingUser(testUser).usingResource(folderA).createContent(inheritedProtectedFile);
JsonObject mixedPerm = Json.createObjectBuilder()
.add("permissions", Json.createObjectBuilder()
.add("isInheritanceEnabled", false)
.add("locallySet", Json.createArrayBuilder()
.add(Json.createObjectBuilder()
.add("authorityId", individualUser.getUsername())
.add("name", "SiteContributor")
.add("accessStatus", "ALLOWED"))
.add(Json.createObjectBuilder()
.add("authorityId", outsiderUser.getUsername())
.add("name", "SiteContributor")
.add("accessStatus", "DENIED"))))
.build();
restClient.authenticateUser(testUser).withCoreAPI().usingNode(mixedPermsFile).updateNode(mixedPerm.toString());
waitForMetadataIndexing(groupProtectedFile.getName(), true);
waitForMetadataIndexing(mixedPermsFile.getName(), true);
waitForMetadataIndexing(inheritedProtectedFile.getName(), true);
}
@Test(priority = 1)
public void testGroupBasedPermissions()
{
SearchResponse groupMember = queryAsUser(groupUser, "cm:name:'" + groupProtectedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(groupMember.getPagination().getCount(), 1,
"Group member should find the group-protected file");
Assert.assertTrue(isContentInSearchResponse(groupMember, groupProtectedFile.getName()),
"Expected the returned entry to be " + groupProtectedFile.getName());
SearchResponse outsider = queryAsUser(outsiderUser, "cm:name:'" + groupProtectedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(outsider.getPagination().getCount(), 0,
"Non-group user should NOT find the group-protected file");
}
@Test(priority = 2)
public void testInheritedGroupPermissionOnFolder()
{
SearchResponse groupMember = queryAsUser(groupUser, "cm:name:'" + inheritedProtectedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(groupMember.getPagination().getCount(), 1,
"Group member should find the file that inherits the folder ACL");
Assert.assertTrue(isContentInSearchResponse(groupMember, inheritedProtectedFile.getName()),
"Expected the returned entry to be " + inheritedProtectedFile.getName());
SearchResponse outsider = queryAsUser(outsiderUser, "cm:name:'" + inheritedProtectedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(outsider.getPagination().getCount(), 0,
"Non-group user should not find the inherited-permission file");
}
@Test(priority = 3)
public void testMixedAllowDenyOnSameNode()
{
SearchResponse allowed = queryAsUser(individualUser, "cm:name:'" + mixedPermsFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(allowed.getPagination().getCount(), 1,
"Explicitly ALLOWED user should find the file");
Assert.assertTrue(isContentInSearchResponse(allowed, mixedPermsFile.getName()),
"Expected the returned entry to be " + mixedPermsFile.getName());
SearchResponse denied = queryAsUser(outsiderUser, "cm:name:'" + mixedPermsFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(denied.getPagination().getCount(), 0,
"Explicitly DENIED user should not find the file");
}
@Test(priority = 4)
public void testAdminSeesAllRegardlessOfAcl()
{
SearchResponse groupProtected = queryAsUser(dataUser.getAdminUser(),
"cm:name:'" + groupProtectedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(groupProtected.getPagination().getCount() >= 1,
"Admin should find the group-protected file regardless of ACL");
Assert.assertTrue(isContentInSearchResponse(groupProtected, groupProtectedFile.getName()),
"Expected " + groupProtectedFile.getName() + " in the admin results");
SearchResponse mixedPerms = queryAsUser(dataUser.getAdminUser(),
"cm:name:'" + mixedPermsFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(mixedPerms.getPagination().getCount() >= 1,
"Admin should also find the mixed-permission file");
Assert.assertTrue(isContentInSearchResponse(mixedPerms, mixedPermsFile.getName()),
"Expected " + mixedPermsFile.getName() + " in the admin results");
}
@Test(priority = 5)
public void testMultipleUsersInGroupSeeSameFile()
{
SearchResponse firstMember = queryAsUser(groupUser, "cm:name:'" + groupProtectedFile.getName() + "'");
SearchResponse secondMember = queryAsUser(secondGroupUser, "cm:name:'" + groupProtectedFile.getName() + "'");
Assert.assertEquals(firstMember.getPagination().getCount(), 1,
"First group member should find the group-protected file");
Assert.assertTrue(isContentInSearchResponse(firstMember, groupProtectedFile.getName()),
"Expected first member's result to be " + groupProtectedFile.getName());
Assert.assertEquals(secondMember.getPagination().getCount(), 1,
"Second group member should also find the group-protected file");
Assert.assertTrue(isContentInSearchResponse(secondMember, groupProtectedFile.getName()),
"Expected second member's result to be " + groupProtectedFile.getName());
}
@Test(priority = 6)
public void testNewFileInProtectedFolderInheritsGroupAcl()
{
FileModel newlyCreatedFile = new FileModel("newly-created-in-folder-a.txt", FileType.TEXT_PLAIN,
"Created after ACL was applied");
dataContent.usingUser(testUser).usingResource(folderA).createContent(newlyCreatedFile);
waitForMetadataIndexing(newlyCreatedFile.getName(), true);
SearchResponse groupMember = queryAsUser(groupUser, "cm:name:'" + newlyCreatedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(groupMember.getPagination().getCount(), 1,
"Group member should find the newly created file inheriting the folder ACL");
Assert.assertTrue(isContentInSearchResponse(groupMember, newlyCreatedFile.getName()),
"Expected the returned entry to be " + newlyCreatedFile.getName());
SearchResponse outsider = queryAsUser(outsiderUser, "cm:name:'" + newlyCreatedFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(outsider.getPagination().getCount(), 0,
"Non-group user should not find the newly created file inheriting the folder ACL");
}
}
@@ -0,0 +1,245 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for deep folder hierarchies (path indexing verification) on Elasticsearch.
*/
public class SearchDeepFolderHierarchyTest extends AbstractSearchServicesE2ETest
{
private FolderModel levelOne;
private FolderModel levelTwo;
private FolderModel levelThree;
private FolderModel levelFour;
private FolderModel levelFive;
private FolderModel levelSix;
private FileModel deepFile;
private FileModel additionalDeepFileOne;
private FileModel additionalDeepFileTwo;
private String pathBase;
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
levelOne = dataContent.usingUser(testUser).usingSite(testSite)
.createFolderCmisApi("level-one-folder");
levelTwo = dataContent.usingUser(testUser).usingSite(testSite).usingResource(levelOne)
.createFolderCmisApi("level-two-folder");
levelThree = dataContent.usingUser(testUser).usingSite(testSite).usingResource(levelTwo)
.createFolderCmisApi("level-three-folder");
levelFour = dataContent.usingUser(testUser).usingSite(testSite).usingResource(levelThree)
.createFolderCmisApi("level-four-folder");
levelFive = dataContent.usingUser(testUser).usingSite(testSite).usingResource(levelFour)
.createFolderCmisApi("level-five-folder");
levelSix = dataContent.usingUser(testUser).usingSite(testSite).usingResource(levelFive)
.createFolderCmisApi("level-six-folder");
deepFile = new FileModel("deep-nested-file.txt");
deepFile.setContent("content at the deepest level");
dataContent.usingUser(testUser).usingResource(levelSix).createContent(deepFile);
additionalDeepFileOne = new FileModel("deep-nested-file-two.txt");
additionalDeepFileOne.setContent("second file in the same deepest folder");
dataContent.usingUser(testUser).usingResource(levelSix).createContent(additionalDeepFileOne);
additionalDeepFileTwo = new FileModel("deep-nested-file-three.txt");
additionalDeepFileTwo.setContent("third file in the same deepest folder");
dataContent.usingUser(testUser).usingResource(levelSix).createContent(additionalDeepFileTwo);
pathBase = "/app:company_home/st:sites/cm:" + testSite.getTitle() + "/cm:documentLibrary";
waitForMetadataIndexing(deepFile.getName(), true);
waitForMetadataIndexing(additionalDeepFileOne.getName(), true);
waitForMetadataIndexing(additionalDeepFileTwo.getName(), true);
}
@Test(priority = 1)
public void testPathQueryAtDeepLevel()
{
String deepPath = pathBase +
"/cm:" + levelOne.getName() +
"/cm:" + levelTwo.getName() +
"/cm:" + levelThree.getName() +
"/cm:" + levelFour.getName() +
"/cm:" + levelFive.getName() +
"/cm:" + levelSix.getName() +
"/cm:" + deepFile.getName();
SearchResponse response = queryAsUser(testUser, "PATH:\"" + deepPath + "\"");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected the deeply-nested file to be found at its exact path");
Assert.assertTrue(isContentInSearchResponse(response, deepFile.getName()),
"Expected the returned entry to be " + deepFile.getName());
}
@Test(priority = 2)
public void testWildcardPathAcrossLevels()
{
String wildcardPath = pathBase +
"/cm:" + levelOne.getName() +
"//*";
SearchResponse response = queryAsUser(testUser,
"PATH:\"" + wildcardPath + "\" AND cm:name:'" + deepFile.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected the deeply-nested file to be found via wildcard path");
Assert.assertTrue(isContentInSearchResponse(response, deepFile.getName()),
"Expected the returned entry to be " + deepFile.getName());
}
@Test(priority = 3)
public void testPathQueryAtIntermediateLevel()
{
String intermediatePath = pathBase +
"/cm:" + levelOne.getName() +
"/cm:" + levelTwo.getName() +
"/cm:" + levelThree.getName();
SearchResponse response = queryAsUser(testUser, "PATH:\"" + intermediatePath + "\"");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected the intermediate-level folder to be found at its path");
Assert.assertTrue(isContentInSearchResponse(response, levelThree.getName()),
"Expected the returned entry to be " + levelThree.getName());
}
@Test(priority = 4)
public void testParentQueryAtDeepLevel()
{
String deepestFolderPath = pathBase +
"/cm:" + levelOne.getName() +
"/cm:" + levelTwo.getName() +
"/cm:" + levelThree.getName() +
"/cm:" + levelFour.getName() +
"/cm:" + levelFive.getName() +
"/cm:" + levelSix.getName();
// Poll until the deepest folder is indexed, so the lookup below can't hit an empty result.
Assert.assertTrue(isContentInSearchResults("PATH:\"" + deepestFolderPath + "\"", levelSix.getName(), true),
"Deepest folder should be findable via PATH before running the PARENT query");
SearchResponse folderLookup = queryAsUser(testUser, "PATH:\"" + deepestFolderPath + "\"");
Assert.assertFalse(folderLookup.getEntries().isEmpty(),
"PATH lookup for the deepest folder returned no entries");
String folderId = folderLookup.getEntries().getFirst().getModel().getId();
String parentQuery = "PARENT:'workspace://SpacesStore/" + folderId +
"' AND cm:name:'" + deepFile.getName() + "'";
SearchResponse response = queryAsUser(testUser, parentQuery);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected the file to be found as the direct child of the deepest folder");
Assert.assertTrue(isContentInSearchResponse(response, deepFile.getName()),
"Expected the returned entry to be " + deepFile.getName());
}
@Test(priority = 5)
public void testPathQueryAtRootOfHierarchy()
{
String rootPath = pathBase + "/cm:" + levelOne.getName();
SearchResponse response = queryAsUser(testUser, "PATH:\"" + rootPath + "\"");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected the level-one folder to be found at the root of the hierarchy");
Assert.assertTrue(isContentInSearchResponse(response, levelOne.getName()),
"Expected the returned entry to be " + levelOne.getName());
}
@Test(priority = 6)
public void testMultipleFilesInSameDeepFolder()
{
String deepFolderPath = pathBase +
"/cm:" + levelOne.getName() +
"/cm:" + levelTwo.getName() +
"/cm:" + levelThree.getName() +
"/cm:" + levelFour.getName() +
"/cm:" + levelFive.getName() +
"/cm:" + levelSix.getName() +
"/*";
SearchResponse response = queryAsUser(testUser, "PATH:\"" + deepFolderPath + "\"");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 3,
"Expected all three files at the deepest level to be found");
Assert.assertTrue(isContentInSearchResponse(response, deepFile.getName()),
"Expected " + deepFile.getName() + " among the deepest-folder children");
Assert.assertTrue(isContentInSearchResponse(response, additionalDeepFileOne.getName()),
"Expected " + additionalDeepFileOne.getName() + " among the deepest-folder children");
Assert.assertTrue(isContentInSearchResponse(response, additionalDeepFileTwo.getName()),
"Expected " + additionalDeepFileTwo.getName() + " among the deepest-folder children");
}
@Test(priority = 7)
public void testTypeAndPathCombined()
{
String deepFolderPath = pathBase +
"/cm:" + levelOne.getName() +
"//*";
String query = "TYPE:'cm:content' AND PATH:\"" + deepFolderPath + "\"";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 3,
"Expected at least the three files under the hierarchy to be returned by TYPE+PATH");
Assert.assertTrue(isContentInSearchResponse(response, deepFile.getName()),
"Expected " + deepFile.getName() + " in the TYPE+PATH results");
Assert.assertTrue(isContentInSearchResponse(response, additionalDeepFileOne.getName()),
"Expected " + additionalDeepFileOne.getName() + " in the TYPE+PATH results");
Assert.assertTrue(isContentInSearchResponse(response, additionalDeepFileTwo.getName()),
"Expected " + additionalDeepFileTwo.getName() + " in the TYPE+PATH results");
}
@Test(priority = 8)
public void testDirectChildrenViaPathWildcard()
{
String midLevelChildrenPath = pathBase +
"/cm:" + levelOne.getName() +
"/cm:" + levelTwo.getName() +
"/cm:" + levelThree.getName() +
"/*";
SearchResponse response = queryAsUser(testUser, "PATH:\"" + midLevelChildrenPath + "\"");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected exactly the level-four folder as the direct child of level-three");
Assert.assertTrue(isContentInSearchResponse(response, levelFour.getName()),
"Expected the returned entry to be " + levelFour.getName());
}
}
@@ -0,0 +1,143 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for multi-language content search on Elasticsearch. Uses only default ES analyzer behaviour — no cross-locale settings required.
*/
public class SearchMultiLanguageTest extends AbstractSearchServicesE2ETest
{
private FileModel frenchContent;
private FileModel spanishContent;
private FileModel mixedLanguageContent;
private static final String UNIQUE_PREFIX = "acsmiglang";
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
FolderModel folder = dataContent.usingUser(testUser).usingSite(testSite)
.createFolderCmisApi(UNIQUE_PREFIX + "-folder");
frenchContent = new FileModel(UNIQUE_PREFIX + "-french-menu.txt");
frenchContent.setContent("Le café propose des croissants et des baguettes traditionnelles.");
dataContent.usingUser(testUser).usingResource(folder).createContent(frenchContent);
spanishContent = new FileModel(UNIQUE_PREFIX + "-spanish-note.txt");
spanishContent.setContent("El niño está jugando en el jardín con su mamá.");
dataContent.usingUser(testUser).usingResource(folder).createContent(spanishContent);
mixedLanguageContent = new FileModel(UNIQUE_PREFIX + "-mixed-language.txt");
mixedLanguageContent.setContent("Hello world. Bonjour tout le monde. Hola mundo. Guten Tag.");
dataContent.usingUser(testUser).usingResource(folder).createContent(mixedLanguageContent);
waitForContentIndexing(frenchContent.getContent(), true);
waitForContentIndexing(spanishContent.getContent(), true);
waitForContentIndexing(mixedLanguageContent.getContent(), true);
}
@Test(priority = 1)
public void testFrenchContentSearchByAsciiTerm()
{
SearchResponse response = queryAsUser(testUser,
"cm:content:'croissants' AND cm:name:'" + frenchContent.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected French content to be found via an ASCII term inside it");
Assert.assertTrue(isContentInSearchResponse(response, frenchContent.getName()),
"Expected the returned entry to be " + frenchContent.getName());
}
@Test(priority = 2)
public void testSpanishContentSearchByAsciiTerm()
{
SearchResponse response = queryAsUser(testUser,
"cm:content:'jugando' AND cm:name:'" + spanishContent.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected Spanish content to be found via an ASCII term inside it");
Assert.assertTrue(isContentInSearchResponse(response, spanishContent.getName()),
"Expected the returned entry to be " + spanishContent.getName());
}
@Test(priority = 3)
public void testMixedLanguageFullText()
{
SearchResponse english = queryAsUser(testUser,
"cm:content:'Hello' AND cm:name:'" + mixedLanguageContent.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(english.getPagination().getCount(), 1, "Expected file found via English term");
Assert.assertTrue(isContentInSearchResponse(english, mixedLanguageContent.getName()),
"Expected the English-term result to be " + mixedLanguageContent.getName());
SearchResponse spanish = queryAsUser(testUser,
"cm:content:'mundo' AND cm:name:'" + mixedLanguageContent.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(spanish.getPagination().getCount(), 1, "Expected file found via Spanish term");
Assert.assertTrue(isContentInSearchResponse(spanish, mixedLanguageContent.getName()),
"Expected the Spanish-term result to be " + mixedLanguageContent.getName());
SearchResponse german = queryAsUser(testUser,
"cm:content:'Guten' AND cm:name:'" + mixedLanguageContent.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(german.getPagination().getCount(), 1, "Expected file found via German term");
Assert.assertTrue(isContentInSearchResponse(german, mixedLanguageContent.getName()),
"Expected the German-term result to be " + mixedLanguageContent.getName());
}
@Test(priority = 4)
public void testCommonWordFoundAcrossMultipleFiles()
{
SearchResponse response = queryAsUser(testUser,
"cm:content:'Hola' AND cm:name:'" + UNIQUE_PREFIX + "*'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected the shared Spanish greeting to be found in at least one migration test file");
Assert.assertTrue(isContentInSearchResponse(response, mixedLanguageContent.getName()),
"Expected " + mixedLanguageContent.getName() + " to contain the shared Spanish greeting");
}
@Test(priority = 5)
public void testWildcardSearchOnMultilingualContent()
{
SearchResponse response = queryAsUser(testUser,
"cm:content:'crois*' AND cm:name:'" + frenchContent.getName() + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected wildcard search to hit 'croissants' in the French content file");
Assert.assertTrue(isContentInSearchResponse(response, frenchContent.getName()),
"Expected the returned entry to be " + frenchContent.getName());
}
}
@@ -0,0 +1,251 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.rest.exception.EmptyRestModelCollectionException;
import org.alfresco.rest.model.RestNodeAssociationModelCollection;
import org.alfresco.rest.model.RestNodeChildAssociationModel;
import org.alfresco.utility.data.CustomObjectTypeProperties;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for advanced secondary parent/child association scenarios on Elasticsearch.
*/
public class SearchSecondaryAssociationAdvancedTest extends AbstractSearchServicesE2ETest
{
private FolderModel primaryFolder;
private FolderModel secondaryFolderA;
private FolderModel secondaryFolderB;
private FolderModel nestedParent;
private FolderModel nestedChild;
private FolderModel commonSecondaryFolder;
private FileModel file;
private FileModel nestedTargetFile;
private FileModel siblingFileOne;
private FileModel siblingFileTwo;
private String pathBase;
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
primaryFolder = new FolderModel("primary-parent-folder");
secondaryFolderA = new FolderModel("secondary-parent-folder-a");
secondaryFolderB = new FolderModel("secondary-parent-folder-b");
nestedParent = new FolderModel("nested-parent-folder");
nestedChild = new FolderModel("nested-child-folder");
commonSecondaryFolder = new FolderModel("common-secondary-folder");
file = new FileModel("multi-secondary-file.txt");
file.setContent("File that will have multiple secondary parents");
nestedTargetFile = new FileModel("nested-secondary-file.txt");
nestedTargetFile.setContent("File to be secondary-associated to a nested folder");
siblingFileOne = new FileModel("sibling-file-one.txt");
siblingFileTwo = new FileModel("sibling-file-two.txt");
dataContent.usingUser(testUser).usingSite(testSite)
.createCustomContent(primaryFolder, "cmis:folder", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingSite(testSite)
.createCustomContent(secondaryFolderA, "cmis:folder", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingSite(testSite)
.createCustomContent(secondaryFolderB, "cmis:folder", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingSite(testSite)
.createCustomContent(nestedParent, "cmis:folder", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingResource(nestedParent)
.createCustomContent(nestedChild, "cmis:folder", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingSite(testSite)
.createCustomContent(commonSecondaryFolder, "cmis:folder", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingResource(primaryFolder)
.createCustomContent(file, "cmis:document", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingResource(primaryFolder)
.createCustomContent(nestedTargetFile, "cmis:document", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingResource(primaryFolder)
.createCustomContent(siblingFileOne, "cmis:document", new CustomObjectTypeProperties());
dataContent.usingUser(testUser).usingResource(primaryFolder)
.createCustomContent(siblingFileTwo, "cmis:document", new CustomObjectTypeProperties());
pathBase = "/app:company_home/st:sites/cm:" + testSite.getTitle() + "/cm:documentLibrary";
waitForMetadataIndexing(file.getName(), true);
waitForMetadataIndexing(nestedTargetFile.getName(), true);
waitForMetadataIndexing(siblingFileOne.getName(), true);
waitForMetadataIndexing(siblingFileTwo.getName(), true);
}
@Test(priority = 1)
public void testMultipleSecondaryAssociationsInSameSite() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assocA = new RestNodeChildAssociationModel(file.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).addSecondaryChildren(assocA);
RestNodeChildAssociationModel assocB = new RestNodeChildAssociationModel(file.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderB).addSecondaryChildren(assocB);
String pathViaA = pathBase + "/cm:" + secondaryFolderA.getName() + "/cm:" + file.getName();
String pathViaB = pathBase + "/cm:" + secondaryFolderB.getName() + "/cm:" + file.getName();
String pathViaPrimary = pathBase + "/cm:" + primaryFolder.getName() + "/cm:" + file.getName();
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaA + "\"", file.getName(), true),
"File not findable via first secondary parent path");
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaB + "\"", file.getName(), true),
"File not findable via second secondary parent path");
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaPrimary + "\"", file.getName(), true),
"File not findable via primary parent path");
RestNodeAssociationModelCollection secChildrenA = restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).removeSecondaryChild(secChildrenA.getEntryByIndex(0));
RestNodeAssociationModelCollection secChildrenB = restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderB).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderB).removeSecondaryChild(secChildrenB.getEntryByIndex(0));
}
@Test(priority = 2)
public void testNestedSecondaryPathQuery() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assoc = new RestNodeChildAssociationModel(nestedTargetFile.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(nestedChild).addSecondaryChildren(assoc);
String pathViaNestedSecondary = pathBase +
"/cm:" + nestedParent.getName() +
"/cm:" + nestedChild.getName() +
"/cm:" + nestedTargetFile.getName();
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaNestedSecondary + "\"", nestedTargetFile.getName(), true),
"File not findable via nested secondary parent path");
RestNodeAssociationModelCollection secChildren = restClient.authenticateUser(testUser).withCoreAPI().usingResource(nestedChild).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(nestedChild).removeSecondaryChild(secChildren.getEntryByIndex(0));
}
@Test(priority = 3)
public void testSecondaryAssociationRemovalUpdatesIndex() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assoc = new RestNodeChildAssociationModel(file.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).addSecondaryChildren(assoc);
String pathViaSecondary = pathBase + "/cm:" + secondaryFolderA.getName() + "/cm:" + file.getName();
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaSecondary + "\"", file.getName(), true),
"File should be findable after secondary association is added");
RestNodeAssociationModelCollection secChildren = restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).removeSecondaryChild(secChildren.getEntryByIndex(0));
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaSecondary + "\"", file.getName(), false),
"File should NOT be findable after secondary association is removed");
}
@Test(priority = 4)
public void testFileFoundOnceGloballyDespiteMultipleAssociations() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assocA = new RestNodeChildAssociationModel(file.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).addSecondaryChildren(assocA);
RestNodeChildAssociationModel assocB = new RestNodeChildAssociationModel(file.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderB).addSecondaryChildren(assocB);
SearchResponse response = queryAsUser(testUser, "cm:name:'" + file.getName() + "'");
Assert.assertEquals(response.getPagination().getCount(), 1,
"Node with multiple secondary parents should still be indexed as a single entry");
Assert.assertTrue(isContentInSearchResponse(response, file.getName()),
"Expected the single returned entry to be " + file.getName());
RestNodeAssociationModelCollection secChildrenA = restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).removeSecondaryChild(secChildrenA.getEntryByIndex(0));
RestNodeAssociationModelCollection secChildrenB = restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderB).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderB).removeSecondaryChild(secChildrenB.getEntryByIndex(0));
}
@Test(priority = 5)
public void testMultipleFilesInSameSecondaryFolder() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assoc1 = new RestNodeChildAssociationModel(siblingFileOne.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(commonSecondaryFolder).addSecondaryChildren(assoc1);
RestNodeChildAssociationModel assoc2 = new RestNodeChildAssociationModel(siblingFileTwo.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(commonSecondaryFolder).addSecondaryChildren(assoc2);
String pathViaCommon = pathBase + "/cm:" + commonSecondaryFolder.getName() + "/*";
// Wait for the secondary-association PATH to be indexed for both files
// (batch-indexing catches up on association changes with a small lag).
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaCommon + "\"", siblingFileOne.getName(), true),
"First file should be findable via the common secondary folder path");
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaCommon + "\"", siblingFileTwo.getName(), true),
"Second file should be findable via the common secondary folder path");
SearchResponse response = queryAsUser(testUser, "PATH:\"" + pathViaCommon + "\"");
Assert.assertEquals(response.getPagination().getCount(), 2,
"Both files should be findable via the common secondary folder path");
Assert.assertTrue(isContentInSearchResponse(response, siblingFileOne.getName()),
"Expected " + siblingFileOne.getName() + " among the common secondary folder children");
Assert.assertTrue(isContentInSearchResponse(response, siblingFileTwo.getName()),
"Expected " + siblingFileTwo.getName() + " among the common secondary folder children");
RestNodeAssociationModelCollection secChildren = restClient.authenticateUser(testUser).withCoreAPI().usingResource(commonSecondaryFolder).getSecondaryChildren();
while (!secChildren.getEntries().isEmpty())
{
restClient.authenticateUser(testUser).withCoreAPI().usingResource(commonSecondaryFolder).removeSecondaryChild(secChildren.getEntryByIndex(0));
secChildren = restClient.authenticateUser(testUser).withCoreAPI().usingResource(commonSecondaryFolder).getSecondaryChildren();
}
}
@Test(priority = 6)
public void testPathViaPrimaryStillWorksAfterSecondaryAdded() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assoc = new RestNodeChildAssociationModel(file.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).addSecondaryChildren(assoc);
String pathViaPrimary = pathBase + "/cm:" + primaryFolder.getName() + "/cm:" + file.getName();
Assert.assertTrue(isContentInSearchResults("PATH:\"" + pathViaPrimary + "\"", file.getName(), true),
"File must still be findable via primary parent path after a secondary association is added");
RestNodeAssociationModelCollection secChildren = restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(secondaryFolderA).removeSecondaryChild(secChildren.getEntryByIndex(0));
}
@Test(priority = 7)
public void testSecondaryAssociationOnDeeplyNestedFileFoundViaSecondaryPath() throws EmptyRestModelCollectionException
{
RestNodeChildAssociationModel assoc = new RestNodeChildAssociationModel(nestedTargetFile.getNodeRefWithoutVersion(), "cm:contains");
restClient.authenticateUser(testUser).withCoreAPI().usingResource(nestedChild).addSecondaryChildren(assoc);
String secondaryDeepPath = pathBase +
"/cm:" + nestedParent.getName() +
"/cm:" + nestedChild.getName() +
"/cm:" + nestedTargetFile.getName();
Assert.assertTrue(isContentInSearchResults("PATH:\"" + secondaryDeepPath + "\"", nestedTargetFile.getName(), true),
"File must be findable at its secondary deep path");
RestNodeAssociationModelCollection secChildren = restClient.authenticateUser(testUser).withCoreAPI().usingResource(nestedChild).getSecondaryChildren();
restClient.authenticateUser(testUser).withCoreAPI().usingResource(nestedChild).removeSecondaryChild(secChildren.getEntryByIndex(0));
}
}
@@ -0,0 +1,192 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.search;
import org.springframework.http.HttpStatus;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FolderModel;
/**
* Migration test class for tag-based search scenarios on Elasticsearch.
*/
public class SearchTagsTest extends AbstractSearchServicesE2ETest
{
private FileModel fileWithSingleTag;
private FileModel fileWithMultipleTags;
private FileModel anotherFileWithTagOne;
private FileModel fileWithAllThreeTags;
private static final String TAG_PREFIX = "acsmigrationtag";
private static final String TAG_ONE = TAG_PREFIX + "one";
private static final String TAG_TWO = TAG_PREFIX + "two";
private static final String TAG_THREE = TAG_PREFIX + "three";
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
FolderModel folder = dataContent.usingUser(testUser).usingSite(testSite).createFolderCmisApi("tags-folder");
fileWithSingleTag = new FileModel("file-with-single-tag.txt");
fileWithSingleTag.setContent("File with a single tag");
dataContent.usingUser(testUser).usingResource(folder).createContent(fileWithSingleTag);
fileWithMultipleTags = new FileModel("file-with-multiple-tags.txt");
fileWithMultipleTags.setContent("File with multiple tags");
dataContent.usingUser(testUser).usingResource(folder).createContent(fileWithMultipleTags);
anotherFileWithTagOne = new FileModel("another-file-with-tag-one.txt");
anotherFileWithTagOne.setContent("Second file also tagged with TAG_ONE");
dataContent.usingUser(testUser).usingResource(folder).createContent(anotherFileWithTagOne);
fileWithAllThreeTags = new FileModel("file-with-all-three-tags.txt");
fileWithAllThreeTags.setContent("File tagged with all three migration tags");
dataContent.usingUser(testUser).usingResource(folder).createContent(fileWithAllThreeTags);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithSingleTag).addTag(TAG_ONE);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithMultipleTags).addTag(TAG_TWO);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithMultipleTags).addTag(TAG_THREE);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(anotherFileWithTagOne).addTag(TAG_ONE);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithAllThreeTags).addTag(TAG_ONE);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithAllThreeTags).addTag(TAG_TWO);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(fileWithAllThreeTags).addTag(TAG_THREE);
waitForMetadataIndexing(fileWithSingleTag.getName(), true);
waitForMetadataIndexing(fileWithMultipleTags.getName(), true);
waitForMetadataIndexing(anotherFileWithTagOne.getName(), true);
waitForMetadataIndexing(fileWithAllThreeTags.getName(), true);
// Wait until the TAG index catches up — batch-indexing needs time after tag-aspect
// and cm:categories property changes before TAG queries start returning results.
Assert.assertTrue(isContentInSearchResults("TAG:'" + TAG_ONE + "'", fileWithSingleTag.getName(), true),
"Setup: TAG_ONE should be searchable after batch-indexing catches up");
Assert.assertTrue(isContentInSearchResults("TAG:'" + TAG_TWO + "'", fileWithMultipleTags.getName(), true),
"Setup: TAG_TWO should be searchable after batch-indexing catches up");
Assert.assertTrue(isContentInSearchResults("TAG:'" + TAG_THREE + "'", fileWithMultipleTags.getName(), true),
"Setup: TAG_THREE should be searchable after batch-indexing catches up");
}
@Test(priority = 1)
public void testSearchByTag()
{
SearchResponse response = queryAsUser(testUser, "TAG:'" + TAG_ONE + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected at least one file tagged with " + TAG_ONE);
Assert.assertTrue(isContentInSearchResponse(response, fileWithSingleTag.getName()),
"Expected " + fileWithSingleTag.getName() + " to be returned for TAG_ONE");
}
@Test(priority = 2)
public void testSearchByMultipleTagsOnSameFile()
{
SearchResponse responseTwo = queryAsUser(testUser, "TAG:'" + TAG_TWO + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(responseTwo.getPagination().getCount() >= 1,
"Expected the multi-tagged file to be findable via TAG_TWO");
Assert.assertTrue(isContentInSearchResponse(responseTwo, fileWithMultipleTags.getName()),
"Expected " + fileWithMultipleTags.getName() + " to be returned for TAG_TWO");
SearchResponse responseThree = queryAsUser(testUser, "TAG:'" + TAG_THREE + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(responseThree.getPagination().getCount() >= 1,
"Expected the multi-tagged file to be findable via TAG_THREE");
Assert.assertTrue(isContentInSearchResponse(responseThree, fileWithMultipleTags.getName()),
"Expected " + fileWithMultipleTags.getName() + " to be returned for TAG_THREE");
}
@Test(priority = 3)
public void testTagAndNameCombined()
{
String query = "TAG:'" + TAG_ONE + "' AND cm:name:'" + fileWithSingleTag.getName() + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertEquals(response.getPagination().getCount(), 1,
"Expected exactly the one tagged file with matching name");
Assert.assertTrue(isContentInSearchResponse(response, fileWithSingleTag.getName()),
"Expected the returned entry to be " + fileWithSingleTag.getName());
}
@Test(priority = 4)
public void testTagWithWildcard()
{
SearchResponse response = queryAsUser(testUser, "TAG:'" + TAG_PREFIX + "*'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 3,
"Expected wildcard TAG query to find all files sharing the migration tag prefix");
Assert.assertTrue(isContentInSearchResponse(response, fileWithSingleTag.getName()),
"Expected " + fileWithSingleTag.getName() + " in the wildcard TAG results");
Assert.assertTrue(isContentInSearchResponse(response, fileWithMultipleTags.getName()),
"Expected " + fileWithMultipleTags.getName() + " in the wildcard TAG results");
Assert.assertTrue(isContentInSearchResponse(response, fileWithAllThreeTags.getName()),
"Expected " + fileWithAllThreeTags.getName() + " in the wildcard TAG results");
}
@Test(priority = 5)
public void testTagsWithConjunction()
{
String query = "TAG:'" + TAG_TWO + "' AND TAG:'" + TAG_THREE + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 1,
"Expected at least one file tagged with both TAG_TWO and TAG_THREE");
Assert.assertTrue(isContentInSearchResponse(response, fileWithMultipleTags.getName()),
"Expected " + fileWithMultipleTags.getName() + " to satisfy the TAG_TWO AND TAG_THREE conjunction");
}
@Test(priority = 6)
public void testTagsWithDisjunction()
{
String query = "TAG:'" + TAG_ONE + "' OR TAG:'" + TAG_TWO + "'";
SearchResponse response = queryAsUser(testUser, query);
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 3,
"Expected disjunction TAG query to return files matching either tag");
Assert.assertTrue(isContentInSearchResponse(response, fileWithSingleTag.getName()),
"Expected " + fileWithSingleTag.getName() + " (TAG_ONE) in the disjunction results");
Assert.assertTrue(isContentInSearchResponse(response, fileWithMultipleTags.getName()),
"Expected " + fileWithMultipleTags.getName() + " (TAG_TWO) in the disjunction results");
}
@Test(priority = 7)
public void testMultipleFilesShareTag()
{
SearchResponse response = queryAsUser(testUser, "TAG:'" + TAG_ONE + "'");
restClient.assertStatusCodeIs(HttpStatus.OK);
Assert.assertTrue(response.getPagination().getCount() >= 3,
"Expected TAG_ONE to be shared by at least three files");
Assert.assertTrue(isContentInSearchResponse(response, fileWithSingleTag.getName()),
"Expected " + fileWithSingleTag.getName() + " to share TAG_ONE");
Assert.assertTrue(isContentInSearchResponse(response, anotherFileWithTagOne.getName()),
"Expected " + anotherFileWithTagOne.getName() + " to share TAG_ONE");
Assert.assertTrue(isContentInSearchResponse(response, fileWithAllThreeTags.getName()),
"Expected " + fileWithAllThreeTags.getName() + " to share TAG_ONE");
}
}
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="ElasticSearchE2ETests-part1" verbose="1" preserve-order="true">
<listeners>
<listener class-name="org.alfresco.utility.report.HtmlReportListener"/>
<listener class-name="org.alfresco.utility.testrail.TestRailExecutorListener"/>
<listener class-name="org.alfresco.utility.testng.OSTestMethodSelector"/>
</listeners>
<test name="SearchTests-Part1">
<classes>
<!-- 1) SearchCasesTest.testSearchExactTermQueries: '=' exact-term operator is disabled by default on Search Enterprise; enable in follow-up PR -->
<class name="org.alfresco.rest.search.SearchCasesTest">
<methods>
<exclude name="testSearchExactTermQueries"/>
</methods>
</class>
<!-- 2) SearchAspectTest.testAspectIsRemovedWithItsProperties: Batch Indexing custom model deployment at runtime gap (https://hyland.atlassian.net/browse/ACS-12502) -->
<class name="org.alfresco.rest.search.SearchAspectTest">
<methods>
<exclude name="testAspectIsRemovedWithItsProperties"/>
</methods>
</class>
<!-- 3) SearchTest: will include all the tests-->
<class name="org.alfresco.rest.search.SearchTest"/>
<!-- 4) SearchSimpleCasesTest: will include all the tests -->
<class name="org.alfresco.rest.search.SearchSimpleCasesTest"/>
<!-- 5) SearchHighLightTest.searchNonIndexedData:AFTS bare-property-name parsing gap on ES (returns HTTP 500) -->
<class name="org.alfresco.rest.search.SearchHighLightTest">
<methods>
<exclude name="searchNonIndexedData"/>
</methods>
</class>
<!-- 6) SearchExactTermTest(Other mentioned tests): ES exact-term validation & cm:name over-match gaps
- exactSearch_phraseInFieldConjunction_shouldReturnFullFieldValueMatch,
- exactSearch_singleTermConjunction_shouldReturnFullFieldValueMatch: Batch Indexing custom model deployment
at runtime gap (tokenised-model.xml 'tok' prefix), https://hyland.atlassian.net/browse/ACS-12502 -->
<class name="org.alfresco.rest.search.SearchExactTermTest">
<methods>
<exclude name="exactSearch_multiTermInFieldWithOnlyUnTokenizedAnalysis_shouldReturnFullFieldValueMatch"/> <!-- pragma: allowlist secret -->
<exclude name="exactSearch_singleTerm_shouldReturnResultsContainingExactTermInName"/>
<exclude name="exactSearch_multiTerm_shouldReturnResultsContainingExactTerm"/>
<exclude name="exactSearch_exactPhrase_shouldReturnResultsContainingExactPhrase"/>
<exclude name="exactSearch_phraseInFieldConjunction_shouldReturnFullFieldValueMatch"/>
<exclude name="exactSearch_singleTermConjunction_shouldReturnFullFieldValueMatch"/>
</methods>
</class>
<!-- 7) SearchSpellCheckTest: need separate profile and Solr specific tests -->
<class name="org.alfresco.rest.search.SearchSpellCheckTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 8) NodeContentTest: excluded as not part of migrated tests for Search API -->
<class name="org.alfresco.rest.search.NodeContentTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 9) SearchNonIndexedFields.testNonIndexedDoesNotExist:uses Solr-only Luke admin endpoint /solr/alfresco/admin/luke
SearchNonIndexedFields.testIndexedAndNonIndexedField():Batch Indexing custom model deployment at runtime gap
(https://hyland.atlassian.net/browse/ACS-12502) -->
<class name="org.alfresco.rest.search.SearchNonIndexedFields">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 10) SearchAPATHTest: APATH is a Solr-only indexed field -->
<class name="org.alfresco.rest.search.SearchAPATHTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 11) SearchWithCustomModelTest: Batch Indexing custom model deployment at runtime gap, https://hyland.atlassian.net/browse/ACS-12502 -->
<class name="org.alfresco.rest.search.SearchWithCustomModelTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
</classes>
</test>
</suite>
@@ -1,63 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd"> <!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="ElasticSearchE2ETests" verbose="1" preserve-order="true"> <suite name="ElasticSearchE2ETests-part2" verbose="1" preserve-order="true">
<listeners> <listeners>
<listener class-name="org.alfresco.utility.report.HtmlReportListener"/> <listener class-name="org.alfresco.utility.report.HtmlReportListener"/>
<listener class-name="org.alfresco.utility.testrail.TestRailExecutorListener"/> <listener class-name="org.alfresco.utility.testrail.TestRailExecutorListener"/>
<listener class-name="org.alfresco.utility.testng.OSTestMethodSelector"/> <listener class-name="org.alfresco.utility.testng.OSTestMethodSelector"/>
</listeners> </listeners>
<test name="SearchTests-Part2">
<test name="SearchTests">
<packages> <packages>
<package name="org.alfresco.rest.search.*"> <package name="org.alfresco.rest.search.*">
<exclude name="org.alfresco.rest.search.fingerprint"/> <exclude name="org.alfresco.rest.search.fingerprint"/>
<exclude name="org.alfresco.rest.search.crosslocale"/> <exclude name="org.alfresco.rest.search.crosslocale"/>
</package> </package>
</packages> </packages>
<classes> <classes>
<class name="org.alfresco.rest.search.SearchSpellCheckTest"> <!-- 1) FacetRangeSearchTest: range facet gaps on ES -->
<class name="org.alfresco.rest.search.FacetRangeSearchTest">
<methods> <methods>
<exclude name=".*"/> <exclude name="searchWithRange"/>
<exclude name="searchWithRangeHardend"/>
<exclude name="searchWithRangeAndIncludeUpperBound"/>
</methods> </methods>
</class> </class>
<class name="org.alfresco.rest.search.NodeContentTest"> <!-- 2) FacetIntervalSearchTest: interval facet gaps on ES -->
<class name="org.alfresco.rest.search.FacetIntervalSearchTest">
<methods> <methods>
<exclude name=".*"/> <exclude name="searchWithBasicInterval"/>
<exclude name="searchWithDates"/>
</methods> </methods>
</class> </class>
<!-- 1) StatsSearchTest: not supported by ES --> <!-- 3) FacetFieldsSearchTest: mincount / SITE facet gaps on ES -->
<class name="org.alfresco.rest.search.FacetFieldsSearchTest">
<methods>
<exclude name="testSearchWithFacetFieldsOnlyFacetsWhereAccess"/>
</methods>
</class>
<!-- 4) StatsSearchTest: not supported by ES -->
<class name="org.alfresco.rest.search.StatsSearchTest"> <class name="org.alfresco.rest.search.StatsSearchTest">
<methods> <methods>
<exclude name=".*"/> <exclude name=".*"/>
</methods> </methods>
</class> </class>
<!-- 2) ShardInfoTest: /private/search/versions/1/info depends on Solr-only shardRegistry bean. <!-- 5) ShardInfoTest: Solr-only API (shardRegistry bean); skip entire class on ES. -->
Keep getShardInfoWithoutAdminAuthority() which only asserts 500 and passes on ES. -->
<class name="org.alfresco.rest.search.ShardInfoTest"> <class name="org.alfresco.rest.search.ShardInfoTest">
<methods>
<exclude name="getShardInfoWithAdminAuthority"/>
</methods>
</class>
<!-- 3) SearchNonIndexedFields.testNonIndexedDoesNotExist:uses Solr-only Luke admin endpoint /solr/alfresco/admin/luke -->
<class name="org.alfresco.rest.search.SearchNonIndexedFields">
<methods>
<exclude name="testNonIndexedDoesNotExist"/>
</methods>
</class>
<!-- 4) SearchHighLightTest.searchNonIndexedData:AFTS bare-property-name parsing gap on ES (returns HTTP 500) -->
<class name="org.alfresco.rest.search.SearchHighLightTest">
<methods>
<exclude name="searchNonIndexedData"/>
</methods>
</class>
<!-- 5) SearchAPATHTest: APATH is a Solr-only indexed field -->
<class name="org.alfresco.rest.search.SearchAPATHTest">
<methods> <methods>
<exclude name=".*"/> <exclude name=".*"/>
</methods> </methods>
</class> </class>
<!-- 6) SearchAFTSInFieldTest: ES tokenizer / cross-locale / ~ operator gaps --> <!-- 6) PivotFacetedSearchTest: not supported by ES -->
<class name="org.alfresco.rest.search.PivotFacetedSearchTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 7) FacetedSearchTest: not supported by ES -->
<class name="org.alfresco.rest.search.FacetedSearchTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 8) FieldDefinitionTest: excluded entirely.
- testTextFieldNotDefined, testmlTextFieldNotDefined, testmlTextFieldMuliple:
unknown-property error handling & mltext stemming gaps on ES.
- Remaining tests: Batch Indexing custom model deployment at runtime gap
(https://hyland.atlassian.net/browse/ACS-12502). -->
<class name="org.alfresco.rest.search.FieldDefinitionTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 9) SearchAFTSInFieldTest: ES tokenizer / cross-locale / ~ operator gaps -->
<class name="org.alfresco.rest.search.SearchAFTSInFieldTest"> <class name="org.alfresco.rest.search.SearchAFTSInFieldTest">
<methods> <methods>
<exclude name="testSearchInFieldName"/> <exclude name="testSearchInFieldName"/>
@@ -66,73 +78,19 @@
<exclude name="testWithConjunctionDisjunctionAndNegation"/> <exclude name="testWithConjunctionDisjunctionAndNegation"/>
</methods> </methods>
</class> </class>
<!-- 7) PivotFacetedSearchTest: not supported by ES -->
<class name="org.alfresco.rest.search.PivotFacetedSearchTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 8) FieldDefinitionTest: unknown-property error handling & mltext stemming gaps --> <!-- Suppress part1 classes so the package sweep doesn't re-run them -->
<class name="org.alfresco.rest.search.FieldDefinitionTest"> <class name="org.alfresco.rest.search.SearchCasesTest"><methods><exclude name=".*"/></methods></class>
<methods> <class name="org.alfresco.rest.search.SearchAspectTest"><methods><exclude name=".*"/></methods></class>
<exclude name="testTextFieldNotDefined"/> <class name="org.alfresco.rest.search.SearchTest"><methods><exclude name=".*"/></methods></class>
<exclude name="testmlTextFieldNotDefined"/> <class name="org.alfresco.rest.search.SearchSimpleCasesTest"><methods><exclude name=".*"/></methods></class>
<exclude name="testmlTextFieldMuliple"/> <class name="org.alfresco.rest.search.SearchHighLightTest"><methods><exclude name=".*"/></methods></class>
</methods> <class name="org.alfresco.rest.search.SearchExactTermTest"><methods><exclude name=".*"/></methods></class>
</class> <class name="org.alfresco.rest.search.SearchSpellCheckTest"><methods><exclude name=".*"/></methods></class>
<class name="org.alfresco.rest.search.NodeContentTest"><methods><exclude name=".*"/></methods></class>
<!-- 9) FacetRangeSearchTest: range facet gaps on ES --> <class name="org.alfresco.rest.search.SearchNonIndexedFields"><methods><exclude name=".*"/></methods></class>
<class name="org.alfresco.rest.search.FacetRangeSearchTest"> <class name="org.alfresco.rest.search.SearchAPATHTest"><methods><exclude name=".*"/></methods></class>
<methods> <class name="org.alfresco.rest.search.SearchWithCustomModelTest"><methods><exclude name=".*"/></methods></class>
<exclude name="searchWithRange"/>
<exclude name="searchWithRangeHardend"/>
<exclude name="searchWithRangeAndIncludeUpperBound"/>
</methods>
</class>
<!-- 10) FacetIntervalSearchTest: interval facet gaps on ES -->
<class name="org.alfresco.rest.search.FacetIntervalSearchTest">
<methods>
<exclude name="searchWithBasicInterval"/>
<exclude name="searchWithDates"/>
</methods>
</class>
<!-- 11) FacetFieldsSearchTest: mincount / SITE facet gaps on ES -->
<class name="org.alfresco.rest.search.FacetFieldsSearchTest">
<methods>
<exclude name="testSearchFacetFieldsBucketExcludedWhenMinCount2"/>
<exclude name="testSearchWithFacetFieldsMinCountChecks"/>
<exclude name="testSearchWithFacetFieldsOnlyFacetsWhereAccess"/>
</methods>
</class>
<!-- 12) FacetedSearchTest: not supported by ES -->
<class name="org.alfresco.rest.search.FacetedSearchTest">
<methods>
<exclude name=".*"/>
</methods>
</class>
<!-- 13) SearchExactTermTest: ES exact-term validation & cm:name over-match gaps -->
<class name="org.alfresco.rest.search.SearchExactTermTest">
<methods>
<exclude name="exactSearch_singleTermConjunction_shouldReturnException"/>
<exclude name="exactSearch_multiTermInFieldWithOnlyUnTokenizedAnalysis_shouldReturnFullFieldValueMatch"/> <!-- pragma: allowlist secret -->
<exclude name="exactSearch_singleTerm_shouldReturnResultsContainingExactTermInName"/>
<exclude name="exactSearch_multiTerm_shouldReturnResultsContainingExactTerm"/>
<exclude name="exactSearch_exactPhrase_shouldReturnResultsContainingExactPhrase"/>
</methods>
</class>
<!-- 14) SearchCasesTest:
- testSearchExactTermQueries: '=' exact-term operator is disabled by default on Search Enterprise; enable in follow-up PR -->
<class name="org.alfresco.rest.search.SearchCasesTest">
<methods>
<exclude name="testSearchExactTermQueries"/>
</methods>
</class>
</classes> </classes>
</test> </test>
</suite> </suite>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="ElasticSearchE2ETests-Part3" verbose="1" preserve-order="true">
<listeners>
<listener class-name="org.alfresco.utility.report.HtmlReportListener"/>
<listener class-name="org.alfresco.utility.testrail.TestRailExecutorListener"/>
<listener class-name="org.alfresco.utility.testng.OSTestMethodSelector"/>
</listeners>
<test name="SearchMigrationAdvancedScenarios">
<classes>
<class name="org.alfresco.rest.search.SearchAdvancedQueryOperatorsTest"/>
<class name="org.alfresco.rest.search.SearchCategoriesTest"/>
<class name="org.alfresco.rest.search.SearchCmisQueriesTest"/>
<class name="org.alfresco.rest.search.SearchComplexAclTest"/>
<class name="org.alfresco.rest.search.SearchDeepFolderHierarchyTest"/>
<class name="org.alfresco.rest.search.SearchMultiLanguageTest"/>
<class name="org.alfresco.rest.search.SearchSecondaryAssociationAdvancedTest"/>
<class name="org.alfresco.rest.search.SearchTagsTest"/>
</classes>
</test>
</suite>
@@ -20,10 +20,11 @@
<!-- <!--
Including the full "org.alfresco.rest.search.*" package pushes Part2 Including the full "org.alfresco.rest.search.*" package pushes Part2
over the 60-min CI limit after the recent test migration. Those tests over the 60-min CI limit after the recent test migration. Those tests
already run in a dedicated CI profile, so only NodeContentTest is already run in a dedicated CI profile, so only the classes below are
kept here to preserve the original Part2 coverage. --> kept here to preserve the original Part2 coverage. -->
<classes> <classes>
<class name="org.alfresco.rest.search.NodeContentTest"/> <class name="org.alfresco.rest.search.NodeContentTest"/>
<class name="org.alfresco.rest.search.DocumentLibraryTagFilterTest"/>
</classes> </classes>
</test> </test>
</suite> </suite>
+1 -1
View File
@@ -9,7 +9,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId> <artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<developers> <developers>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId> <artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<properties> <properties>
+19 -11
View File
@@ -2,7 +2,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<name>Alfresco Community Repo Parent</name> <name>Alfresco Community Repo Parent</name>
@@ -51,20 +51,21 @@
<dependency.alfresco-server-root.version>8.0.1</dependency.alfresco-server-root.version> <dependency.alfresco-server-root.version>8.0.1</dependency.alfresco-server-root.version>
<dependency.activiti-engine.version>5.23.0</dependency.activiti-engine.version> <dependency.activiti-engine.version>5.23.0</dependency.activiti-engine.version>
<dependency.activiti.version>5.23.0</dependency.activiti.version> <dependency.activiti.version>5.23.0</dependency.activiti.version>
<dependency.alfresco-transform-core.version>5.4.4-A.6</dependency.alfresco-transform-core.version> <dependency.alfresco-transform-core.version>5.4.4</dependency.alfresco-transform-core.version>
<dependency.alfresco-transform-service.version>4.4.4-A.7</dependency.alfresco-transform-service.version> <dependency.alfresco-transform-service.version>4.4.4</dependency.alfresco-transform-service.version>
<dependency.alfresco-greenmail.version>7.1</dependency.alfresco-greenmail.version> <dependency.alfresco-greenmail.version>7.1</dependency.alfresco-greenmail.version>
<dependency.acs-event-model.version>1.1.0-A.1</dependency.acs-event-model.version> <dependency.acs-event-model.version>1.1.0-A.1</dependency.acs-event-model.version>
<dependency.aspectj.version>1.9.22.1</dependency.aspectj.version> <dependency.aspectj.version>1.9.22.1</dependency.aspectj.version>
<dependency.spring.version>7.0.8</dependency.spring.version> <dependency.spring.version>7.0.8</dependency.spring.version>
<dependency.spring-security.version>7.0.6</dependency.spring-security.version> <dependency.spring-security.version>7.0.6</dependency.spring-security.version>
<dependency.micrometer.version>1.16.6</dependency.micrometer.version> <!-- should be in sync with micrometer-observation transitive dependency from spring-context -->
<dependency.antlr.version>3.5.3</dependency.antlr.version> <dependency.antlr.version>3.5.3</dependency.antlr.version>
<dependency.jackson.version>2.22.1</dependency.jackson.version> <dependency.jackson.version>2.22.1</dependency.jackson.version>
<dependency.cxf.version>4.1.7</dependency.cxf.version> <dependency.cxf.version>4.1.7</dependency.cxf.version>
<dependency.opencmis.version>1.0.0-jakarta-1</dependency.opencmis.version> <dependency.opencmis.version>1.0.0-jakarta-1</dependency.opencmis.version>
<dependency.webscripts.version>10.2</dependency.webscripts.version> <dependency.webscripts.version>10.2</dependency.webscripts.version>
<dependency.bouncycastle.version>1.84</dependency.bouncycastle.version> <dependency.bouncycastle.version>1.85</dependency.bouncycastle.version>
<dependency.mockito-core.version>5.18.0</dependency.mockito-core.version> <dependency.mockito-core.version>5.18.0</dependency.mockito-core.version>
<dependency.test-parameter-injector.version>1.18</dependency.test-parameter-injector.version> <dependency.test-parameter-injector.version>1.18</dependency.test-parameter-injector.version>
<dependency.assertj.version>3.27.3</dependency.assertj.version> <dependency.assertj.version>3.27.3</dependency.assertj.version>
@@ -75,9 +76,9 @@
<dependency.guava.version>33.3.1-jre</dependency.guava.version> <dependency.guava.version>33.3.1-jre</dependency.guava.version>
<dependency.httpclient.version>4.5.14</dependency.httpclient.version> <dependency.httpclient.version>4.5.14</dependency.httpclient.version>
<dependency.httpcore.version>4.4.16</dependency.httpcore.version> <dependency.httpcore.version>4.4.16</dependency.httpcore.version>
<dependency.httpcomponents-httpclient5.version>5.5</dependency.httpcomponents-httpclient5.version> <dependency.httpcomponents-httpclient5.version>5.6.4</dependency.httpcomponents-httpclient5.version>
<dependency.httpcomponents-httpcore5.version>5.3.4</dependency.httpcomponents-httpcore5.version> <dependency.httpcomponents-httpcore5.version>5.4.3</dependency.httpcomponents-httpcore5.version>
<dependency.httpcomponents-httpcore5-h2.version>5.3.4</dependency.httpcomponents-httpcore5-h2.version> <dependency.httpcomponents-httpcore5-h2.version>5.4.3</dependency.httpcomponents-httpcore5-h2.version>
<dependency.commons-httpclient.version>3.1-HTTPCLIENT-1265</dependency.commons-httpclient.version> <dependency.commons-httpclient.version>3.1-HTTPCLIENT-1265</dependency.commons-httpclient.version>
<dependency.xercesImpl.version>2.12.2</dependency.xercesImpl.version> <dependency.xercesImpl.version>2.12.2</dependency.xercesImpl.version>
<dependency.google-cloud-libraries-bom.version>26.84.0</dependency.google-cloud-libraries-bom.version> <dependency.google-cloud-libraries-bom.version>26.84.0</dependency.google-cloud-libraries-bom.version>
@@ -93,7 +94,7 @@
<dependency.jboss.logging.version>3.5.0.Final</dependency.jboss.logging.version> <dependency.jboss.logging.version>3.5.0.Final</dependency.jboss.logging.version>
<dependency.camel.version>4.18.3</dependency.camel.version> <!-- when bumping this version, please keep track/sync with included netty.io dependencies --> <dependency.camel.version>4.18.3</dependency.camel.version> <!-- when bumping this version, please keep track/sync with included netty.io dependencies -->
<dependency.netty.version>4.1.137.Final</dependency.netty.version> <!-- must be in sync with camels transitive dependencies, e.g.: netty-common --> <dependency.netty.version>4.1.137.Final</dependency.netty.version> <!-- must be in sync with camels transitive dependencies, e.g.: netty-common -->
<dependency.activemq.version>6.2.4</dependency.activemq.version> <dependency.activemq.version>6.2.7</dependency.activemq.version>
<dependency.apache-compress.version>1.27.1</dependency.apache-compress.version> <dependency.apache-compress.version>1.27.1</dependency.apache-compress.version>
<dependency.awaitility.version>4.2.2</dependency.awaitility.version> <dependency.awaitility.version>4.2.2</dependency.awaitility.version>
@@ -123,9 +124,9 @@
<dependency.jakarta-ee-json-impl.version>1.1.8</dependency.jakarta-ee-json-impl.version> <dependency.jakarta-ee-json-impl.version>1.1.8</dependency.jakarta-ee-json-impl.version>
<dependency.jakarta-json-path.version>2.9.0</dependency.jakarta-json-path.version> <dependency.jakarta-json-path.version>2.9.0</dependency.jakarta-json-path.version>
<dependency.json-smart.version>2.5.2</dependency.json-smart.version> <dependency.json-smart.version>2.5.2</dependency.json-smart.version>
<alfresco.googledrive.version>5.0.0-A2</alfresco.googledrive.version> <alfresco.googledrive.version>5.0.0</alfresco.googledrive.version>
<alfresco.aos-module.version>3.4.2-A.3</alfresco.aos-module.version> <alfresco.aos-module.version>3.4.2-A.3</alfresco.aos-module.version>
<alfresco.api-explorer.version>26.2.0</alfresco.api-explorer.version> <!-- Also in alfresco-enterprise-share --> <alfresco.api-explorer.version>26.3.0-A.4</alfresco.api-explorer.version> <!-- Also in alfresco-enterprise-share -->
<alfresco.maven-plugin.version>2.2.0</alfresco.maven-plugin.version> <alfresco.maven-plugin.version>2.2.0</alfresco.maven-plugin.version>
<license-maven-plugin.version>2.4.0</license-maven-plugin.version> <license-maven-plugin.version>2.4.0</license-maven-plugin.version>
@@ -1055,6 +1056,13 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-bom</artifactId>
<version>${dependency.micrometer.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -1207,7 +1215,7 @@
<exclude>jakarta.xml.soap:jakarta.xml.soap-api:(, 2.0.1)</exclude> <exclude>jakarta.xml.soap:jakarta.xml.soap-api:(, 2.0.1)</exclude>
<exclude>jakarta.jws:jakarta.jws-api:(, 3.0.0)</exclude> <exclude>jakarta.jws:jakarta.jws-api:(, 3.0.0)</exclude>
<!-- Enforce ban bouncycastle dependencies other than specified under <includes> section--> <!-- Enforce ban bouncycastle dependencies other than specified under <includes> section-->
<exclude>org.bouncycastle:(,1.84)</exclude> <exclude>org.bouncycastle:(,1.85)</exclude>
<!-- Enforce one version of Jaxb--> <!-- Enforce one version of Jaxb-->
<exclude>com.sun.xml.bind</exclude> <exclude>com.sun.xml.bind</exclude>
</excludes> </excludes>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<dependencies> <dependencies>
@@ -4,7 +4,7 @@
<description>Returns repository information</description> <description>Returns repository information</description>
<url>/discovery</url> <url>/discovery</url>
<authentication>user</authentication> <authentication>user</authentication>
<transaction>required</transaction> <transaction allow="readonly">required</transaction>
<format default="json">argument</format> <format default="json">argument</format>
<lifecycle>public_api</lifecycle> <lifecycle>public_api</lifecycle>
</webscript> </webscript>
@@ -228,6 +228,7 @@
<list> <list>
<value>AdminConsole</value> <value>AdminConsole</value>
<value>AdminConsoleHelper</value> <value>AdminConsoleHelper</value>
<value>Bulk Filesystem Import</value>
</list> </list>
</property> </property>
</bean> </bean>
@@ -2,7 +2,7 @@
* #%L * #%L
* Alfresco Repository * Alfresco Repository
* %% * %%
* Copyright (C) 2005 - 2021 Alfresco Software Limited * Copyright (C) 2005 - 2026 Alfresco Software Limited
* %% * %%
* This file is part of the Alfresco software. * This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of * If the software was purchased under a paid Alfresco license, the terms of
@@ -49,6 +49,7 @@ import org.alfresco.util.testing.category.NonBuildTests;
org.alfresco.rest.api.tests.ActivitiesPostingTest.class, org.alfresco.rest.api.tests.ActivitiesPostingTest.class,
org.alfresco.rest.api.tests.AuthenticationsTest.class, org.alfresco.rest.api.tests.AuthenticationsTest.class,
org.alfresco.rest.api.tests.DiscoveryApiTest.class, org.alfresco.rest.api.tests.DiscoveryApiTest.class,
org.alfresco.rest.api.tests.DiscoveryInReadOnlyModeTest.class,
org.alfresco.rest.api.discovery.DiscoveryApiWebscriptUnitTest.class, org.alfresco.rest.api.discovery.DiscoveryApiWebscriptUnitTest.class,
org.alfresco.rest.api.tests.GroupsTest.class, org.alfresco.rest.api.tests.GroupsTest.class,
org.alfresco.rest.api.tests.ModulePackagesApiTest.class, org.alfresco.rest.api.tests.ModulePackagesApiTest.class,
@@ -221,6 +221,15 @@ public class RemoteAuthenticatorFactoryAdminConsoleAccessTest extends BaseSpring
complexCheckOfScriptCases(families); complexCheckOfScriptCases(families);
} }
@Test
public void testAdminCanAccessBulkFilesystemImportScript()
{
Set<String> families = new HashSet<>();
families.add("Bulk Filesystem Import");
org.junit.Assert.assertTrue(remoteUserAuthenticatorFactory.getAdminConsoleScriptFamilies().containsAll(families));
complexCheckOfScriptCases(families);
}
/** /**
* Tested access to the AdminConsole for an non literal admin user but with admin permissions (user added to ALFRESCO_ADMINISTRATORS) and accessing via Basic Auth * Tested access to the AdminConsole for an non literal admin user but with admin permissions (user added to ALFRESCO_ADMINISTRATORS) and accessing via Basic Auth
*/ */
@@ -0,0 +1,87 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2025 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.rest.api.tests;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import java.io.IOException;
import org.junit.Test;
import org.alfresco.repo.transaction.TransactionServiceImpl;
import org.alfresco.rest.AbstractSingleNetworkSiteTest;
import org.alfresco.rest.api.tests.client.HttpResponse;
import org.alfresco.rest.api.tests.client.data.Node;
import org.alfresco.rest.api.tests.util.RestApiUtil;
import org.alfresco.service.ServiceRegistry;
public class DiscoveryInReadOnlyModeTest extends AbstractSingleNetworkSiteTest
{
@Test
public void testReadOnlyServerCanCallDiscovery() throws Exception
{
TransactionServiceImpl transactionService = (TransactionServiceImpl) applicationContext.getBean(ServiceRegistry.TRANSACTION_SERVICE.getLocalName());
try
{
transactionService.setAllowWrite(false);
setRequestContext(user1);
verifySystemIsInReadOnlyMode();
// discovery must remain accessible even when the repository is in read-only mode
get("discovery", null, 200);
}
finally
{
transactionService.setAllowWrite(true);
}
}
private void verifySystemIsInReadOnlyMode() throws IOException
{
Node n = new Node();
n.setName("test-folder-any-name-" + RUNID);
n.setNodeType(TYPE_CM_FOLDER);
HttpResponse response = publicApiClient.post(getScope(), getNodeChildrenUrl(getNodeId()), null, null, null, RestApiUtil.toJsonAsStringNonNull(n));
assertThat(response.getStatusCode()).isEqualTo(403);
assertThat(response.getResponse()).contains("The system is currently in read-only mode.");
}
private String getNodeId()
{
try
{
return getMyNodeId();
}
catch (Exception e)
{
fail("Test setup failure: unable to get My Node ID", e);
return null;
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId> <artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.36-SNAPSHOT</version> <version>26.3.0.50-SNAPSHOT</version>
</parent> </parent>
<dependencies> <dependencies>
@@ -35,6 +35,7 @@ import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.extensions.surf.util.ParameterCheck; import org.springframework.extensions.surf.util.ParameterCheck;
import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.cache.SimpleCache; import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.repo.cache.TransactionalCache; import org.alfresco.repo.cache.TransactionalCache;
import org.alfresco.repo.cache.lookup.EntityLookupCache; import org.alfresco.repo.cache.lookup.EntityLookupCache;
@@ -42,6 +43,7 @@ import org.alfresco.repo.cache.lookup.EntityLookupCache.EntityLookupCallbackDAO;
import org.alfresco.repo.domain.CrcHelper; import org.alfresco.repo.domain.CrcHelper;
import org.alfresco.repo.domain.qname.QNameDAO; import org.alfresco.repo.domain.qname.QNameDAO;
import org.alfresco.repo.security.permissions.ACEType; import org.alfresco.repo.security.permissions.ACEType;
import org.alfresco.repo.security.permissions.ACLType;
import org.alfresco.repo.security.permissions.PermissionReference; import org.alfresco.repo.security.permissions.PermissionReference;
import org.alfresco.repo.security.permissions.impl.SimplePermissionReference; import org.alfresco.repo.security.permissions.impl.SimplePermissionReference;
import org.alfresco.service.cmr.security.AccessStatus; import org.alfresco.service.cmr.security.AccessStatus;
@@ -252,6 +254,64 @@ public abstract class AbstractAclCrudDAOImpl implements AclCrudDAO
return getLatestAclEntityByGuid(aclGuid); return getLatestAclEntityByGuid(aclGuid);
} }
@Override
public List<Long> getUnusedAclIds(long afterAclId, int maxResults)
{
if (maxResults < 1)
{
throw new IllegalArgumentException("maxResults must be greater than zero");
}
Long sharedAclToReplaceQNameId = getQNameId(ContentModel.PROP_SHARED_ACL_TO_REPLACE);
Long inheritFromAclQNameId = getQNameId(ContentModel.PROP_INHERIT_FROM_ACL);
return getUnusedAclEntityIds(afterAclId, sharedAclToReplaceQNameId, inheritFromAclQNameId,
ACLType.FIXED.getId(), ACLType.GLOBAL.getId(), maxResults);
}
@Override
public boolean deleteUnusedAcl(long aclEntityId)
{
Long sharedAclToReplaceQNameId = getQNameId(ContentModel.PROP_SHARED_ACL_TO_REPLACE);
Long inheritFromAclQNameId = getQNameId(ContentModel.PROP_INHERIT_FROM_ACL);
if (!isAclEntityUnused(aclEntityId, sharedAclToReplaceQNameId, inheritFromAclQNameId,
ACLType.FIXED.getId(), ACLType.GLOBAL.getId()))
{
return false;
}
Acl acl = getAcl(aclEntityId);
if (acl == null)
{
return false;
}
List<AclMember> members = getAclMembersByAcl(aclEntityId);
List<Long> aceIds = new ArrayList<>(members.size());
for (AclMember member : members)
{
aceIds.add(member.getAceId());
}
deleteAclMembersByAcl(aclEntityId);
deleteAcl(aclEntityId);
for (Long aceId : aceIds)
{
deleteAceEntityIfUnused(aceId);
}
if (acl.getAclChangeSetId() != null)
{
deleteAclChangeSetEntityIfUnused(acl.getAclChangeSetId());
}
return true;
}
private Long getQNameId(QName qname)
{
Pair<Long, QName> qnamePair = qnameDAO.getQName(qname);
return qnamePair == null ? null : qnamePair.getFirst();
}
public List<Long> getADMNodesByAcl(long aclEntityId, int maxResults) public List<Long> getADMNodesByAcl(long aclEntityId, int maxResults)
{ {
return getADMNodeEntityIdsByAcl(aclEntityId, maxResults); return getADMNodeEntityIdsByAcl(aclEntityId, maxResults);
@@ -373,12 +433,20 @@ public abstract class AbstractAclCrudDAOImpl implements AclCrudDAO
protected abstract Long getLatestAclEntityByGuid(String aclGuid); protected abstract Long getLatestAclEntityByGuid(String aclGuid);
protected abstract List<Long> getUnusedAclEntityIds(long afterAclId, Long sharedAclToReplaceQNameId, Long inheritFromAclQNameId,
int fixedAclType, int globalAclType, int maxResults);
protected abstract boolean isAclEntityUnused(long aclEntityId, Long sharedAclToReplaceQNameId, Long inheritFromAclQNameId,
int fixedAclType, int globalAclType);
protected abstract int updateAclEntity(AclEntity entity); protected abstract int updateAclEntity(AclEntity entity);
protected abstract int updateAceEntity(AceEntity updatedAceEntity); protected abstract int updateAceEntity(AceEntity updatedAceEntity);
protected abstract int deleteAclEntity(long id); protected abstract int deleteAclEntity(long id);
protected abstract int deleteAclChangeSetEntityIfUnused(long aclChangeSetEntityId);
protected abstract List<Long> getADMNodeEntityIdsByAcl(long aclEntityId, int maxResults); protected abstract List<Long> getADMNodeEntityIdsByAcl(long aclEntityId, int maxResults);
// //
@@ -681,6 +749,8 @@ public abstract class AbstractAclCrudDAOImpl implements AclCrudDAO
protected abstract int deleteAceEntities(List<Long> aceIds); protected abstract int deleteAceEntities(List<Long> aceIds);
protected abstract int deleteAceEntityIfUnused(long aceId);
// //
// Permission // Permission
// //
@@ -63,6 +63,10 @@ public interface AclCrudDAO
public Long getLatestAclByGuid(String aclGuid); public Long getLatestAclByGuid(String aclGuid);
List<Long> getUnusedAclIds(long afterAclId, int maxResults);
boolean deleteUnusedAcl(long aclEntityId);
public void updateAcl(AclUpdateEntity entity); public void updateAcl(AclUpdateEntity entity);
public void deleteAcl(long aclEntityId); public void deleteAcl(long aclEntityId);
@@ -0,0 +1,105 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2016 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.domain.permissions;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.alfresco.service.transaction.TransactionService;
import org.alfresco.util.PropertyCheck;
/**
* Removes a bounded batch of ACLs that have no repository references.
*/
public class UnusedAclCleaner
{
private static final Logger LOGGER = LoggerFactory.getLogger(UnusedAclCleaner.class);
private AclCrudDAO aclCrudDAO;
private TransactionService transactionService;
private int batchSize = 1000;
private boolean enabled = true;
public void setAclCrudDAO(AclCrudDAO aclCrudDAO)
{
this.aclCrudDAO = aclCrudDAO;
}
public void setTransactionService(TransactionService transactionService)
{
this.transactionService = transactionService;
}
public void setBatchSize(int batchSize)
{
if (batchSize < 1)
{
throw new IllegalArgumentException("batchSize must be greater than zero");
}
this.batchSize = batchSize;
}
public void setEnabled(boolean enabled)
{
this.enabled = enabled;
}
public void init()
{
PropertyCheck.mandatory(this, "aclCrudDAO", aclCrudDAO);
PropertyCheck.mandatory(this, "transactionService", transactionService);
}
public int execute()
{
if (!enabled)
{
LOGGER.debug("Unused ACL cleanup is disabled.");
return 0;
}
int deleted = transactionService.getRetryingTransactionHelper()
.doInTransaction(this::cleanupBatch, false, true);
LOGGER.info("Unused ACL cleanup removed {} ACLs.", deleted);
return deleted;
}
int cleanupBatch()
{
List<Long> aclIds = aclCrudDAO.getUnusedAclIds(0, batchSize);
int deleted = 0;
for (Long aclId : aclIds)
{
if (aclCrudDAO.deleteUnusedAcl(aclId))
{
deleted++;
}
}
return deleted;
}
}
@@ -0,0 +1,51 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2016 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.domain.permissions;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.schedule.AbstractScheduledLockedJob;
/**
* Cluster-safe scheduled entry point for unused ACL cleanup.
*/
public class UnusedAclCleanupJob extends AbstractScheduledLockedJob
{
@Override
public void executeJob(JobExecutionContext context) throws JobExecutionException
{
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
Object cleaner = jobDataMap.get("unusedAclCleaner");
if (!(cleaner instanceof UnusedAclCleaner))
{
throw new AlfrescoRuntimeException("UnusedAclCleanupJob must contain a valid 'unusedAclCleaner'");
}
((UnusedAclCleaner) cleaner).execute();
}
}
@@ -59,6 +59,8 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl
private static final String SELECT_ACL_BY_ID = "alfresco.permissions.select_AclById"; private static final String SELECT_ACL_BY_ID = "alfresco.permissions.select_AclById";
private static final String SELECT_ACLS_THAT_INHERIT_FROM_ACL = "alfresco.permissions.select_AclsThatInheritFromAcl"; private static final String SELECT_ACLS_THAT_INHERIT_FROM_ACL = "alfresco.permissions.select_AclsThatInheritFromAcl";
private static final String SELECT_LATEST_ACL_BY_GUID = "alfresco.permissions.select_LatestAclByGuid"; private static final String SELECT_LATEST_ACL_BY_GUID = "alfresco.permissions.select_LatestAclByGuid";
private static final String SELECT_UNUSED_ACL_IDS = "alfresco.permissions.select_UnusedAclIds";
private static final String SELECT_IS_ACL_UNUSED = "alfresco.permissions.select_IsAclUnused";
private static final String SELECT_ADM_NODES_BY_ACL = "alfresco.permissions.select_ADMNodesByAclId"; private static final String SELECT_ADM_NODES_BY_ACL = "alfresco.permissions.select_ADMNodesByAclId";
private static final String UPDATE_ACL = "alfresco.permissions.update_Acl"; private static final String UPDATE_ACL = "alfresco.permissions.update_Acl";
private static final String DELETE_ACL = "alfresco.permissions.delete_Acl"; private static final String DELETE_ACL = "alfresco.permissions.delete_Acl";
@@ -75,6 +77,7 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl
private static final String UPDATE_ACL_CHANGESET = "alfresco.permissions.update_AclChangeSet"; private static final String UPDATE_ACL_CHANGESET = "alfresco.permissions.update_AclChangeSet";
private static final String SELECT_ACL_CHANGESET_BY_ID = "alfresco.permissions.select_AclChangeSetById"; private static final String SELECT_ACL_CHANGESET_BY_ID = "alfresco.permissions.select_AclChangeSetById";
private static final String DELETE_ACL_CHANGESET = "alfresco.permissions.delete_AclChangeSet"; private static final String DELETE_ACL_CHANGESET = "alfresco.permissions.delete_AclChangeSet";
private static final String DELETE_UNUSED_ACL_CHANGESET = "alfresco.permissions.delete_UnusedAclChangeSet";
private static final String INSERT_ACE = "alfresco.permissions.insert.insert_Ace"; private static final String INSERT_ACE = "alfresco.permissions.insert.insert_Ace";
private static final String SELECT_ACE_BY_ID = "alfresco.permissions.select_AceById"; private static final String SELECT_ACE_BY_ID = "alfresco.permissions.select_AceById";
@@ -82,6 +85,7 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl
private static final String SELECT_ACES_AND_AUTHORIES_BY_ACL = "alfresco.permissions.select_AcesAndAuthoritiesByAclId"; private static final String SELECT_ACES_AND_AUTHORIES_BY_ACL = "alfresco.permissions.select_AcesAndAuthoritiesByAclId";
private static final String SELECT_ACE_WITH_NO_CONTEXT = "alfresco.permissions.select_AceWithNoContext"; private static final String SELECT_ACE_WITH_NO_CONTEXT = "alfresco.permissions.select_AceWithNoContext";
private static final String DELETE_ACES_LIST = "alfresco.permissions.delete_AcesList"; private static final String DELETE_ACES_LIST = "alfresco.permissions.delete_AcesList";
private static final String DELETE_UNUSED_ACE = "alfresco.permissions.delete_UnusedAce";
private static final String UPDATE_ACE = "alfresco.permissions.update_Ace"; private static final String UPDATE_ACE = "alfresco.permissions.update_Ace";
private static final String INSERT_ACE_CONTEXT = "alfresco.permissions.insert.insert_AceContext"; private static final String INSERT_ACE_CONTEXT = "alfresco.permissions.insert.insert_AceContext";
@@ -151,6 +155,33 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl
return template.selectOne(SELECT_LATEST_ACL_BY_GUID, params); return template.selectOne(SELECT_LATEST_ACL_BY_GUID, params);
} }
@Override
protected List<Long> getUnusedAclEntityIds(long afterAclId, Long sharedAclToReplaceQNameId, Long inheritFromAclQNameId,
int fixedAclType, int globalAclType, int maxResults)
{
Map<String, Object> params = new HashMap<>(5);
params.put("afterAclId", afterAclId);
params.put("sharedAclToReplaceQNameId", sharedAclToReplaceQNameId);
params.put("inheritFromAclQNameId", inheritFromAclQNameId);
params.put("fixedAclType", fixedAclType);
params.put("globalAclType", globalAclType);
return template.selectList(SELECT_UNUSED_ACL_IDS, params, new RowBounds(0, maxResults));
}
@Override
protected boolean isAclEntityUnused(long aclEntityId, Long sharedAclToReplaceQNameId, Long inheritFromAclQNameId,
int fixedAclType, int globalAclType)
{
Map<String, Object> params = new HashMap<>(5);
params.put("id", aclEntityId);
params.put("sharedAclToReplaceQNameId", sharedAclToReplaceQNameId);
params.put("inheritFromAclQNameId", inheritFromAclQNameId);
params.put("fixedAclType", fixedAclType);
params.put("globalAclType", globalAclType);
return template.selectOne(SELECT_IS_ACL_UNUSED, params) != null;
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Override @Override
protected List<Long> getADMNodeEntityIdsByAcl(long aclEntityId, int maxResults) protected List<Long> getADMNodeEntityIdsByAcl(long aclEntityId, int maxResults)
@@ -284,6 +315,14 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl
return template.delete(DELETE_ACL_CHANGESET, params); return template.delete(DELETE_ACL_CHANGESET, params);
} }
@Override
protected int deleteAclChangeSetEntityIfUnused(long aclChangeSetEntityId)
{
Map<String, Object> params = new HashMap<>(1);
params.put("id", aclChangeSetEntityId);
return template.delete(DELETE_UNUSED_ACL_CHANGESET, params);
}
@Override @Override
protected int updateChangeSetEntity(Long id, long commitTimeMs) protected int updateChangeSetEntity(Long id, long commitTimeMs)
{ {
@@ -361,6 +400,14 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl
return template.delete(DELETE_ACES_LIST, aceEntityIds); return template.delete(DELETE_ACES_LIST, aceEntityIds);
} }
@Override
protected int deleteAceEntityIfUnused(long aceEntityId)
{
Map<String, Object> params = new HashMap<>(1);
params.put("id", aceEntityId);
return template.delete(DELETE_UNUSED_ACE, params);
}
@Override @Override
protected long createAceContextEntity(AceContextEntity entity) protected long createAceContextEntity(AceContextEntity entity)
{ {
@@ -427,6 +427,11 @@ public class ElasticsearchHttpClientFactory
LOGGER.debug("Using default ioThreadCount for Elasticsearch HTTP Client since the specified value was {}.", threadCount); LOGGER.debug("Using default ioThreadCount for Elasticsearch HTTP Client since the specified value was {}.", threadCount);
} }
// httpclient5 5.6 auto-negotiates gzip/x-gzip content encoding on the async transport, but the
// OpenSearch client manages compression itself (defaults to none), so transparent decompression
// makes responses hang/fail. Disable it to restore pre-5.6 behaviour.
httpClientBuilder.disableContentCompression();
httpClientBuilder.setUserAgent(StringUtils.EMPTY).setConnectionManager(connectionBuilder.build()); httpClientBuilder.setUserAgent(StringUtils.EMPTY).setConnectionManager(connectionBuilder.build());
// Build and set the HTTP/2 response timeout using the configured responseTimeout property // Build and set the HTTP/2 response timeout using the configured responseTimeout property
@@ -658,7 +658,14 @@ public class TemplateNode extends BasePermissionsNode implements NamespacePrefix
// TODO URLs for the repo server. // TODO URLs for the repo server.
// TODO URLs for folders // TODO URLs for folders
String siteShortName = services.getSiteService().getSiteShortName(getNodeRef()); // Run site lookup as system to avoid parent-folder permission issues.
String siteShortName = AuthenticationUtil.runAsSystem(new RunAsWork<>() {
@Override
public String doWork() throws Exception
{
return services.getSiteService().getSiteShortName(getNodeRef());
}
});
String baseUrl = UrlUtil.getShareUrl(services.getSysAdminParams()); String baseUrl = UrlUtil.getShareUrl(services.getSysAdminParams());
@@ -679,6 +686,7 @@ public class TemplateNode extends BasePermissionsNode implements NamespacePrefix
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Inner classes // Inner classes
@Override
public NamespacePrefixResolver getNamespacePrefixResolver() public NamespacePrefixResolver getNamespacePrefixResolver()
{ {
return this.services.getNamespaceService(); return this.services.getNamespaceService();
@@ -385,6 +385,52 @@
acl.acl_id = ? and acl.latest = ? acl.acl_id = ? and acl.latest = ?
</select> </select>
<select id="select_UnusedAclIds" parameterType="map" resultType="long">
select
acl.id
from
alf_access_control_list acl
where
acl.id &gt; #{afterAclId,jdbcType=BIGINT} and
acl.type not in (#{fixedAclType,jdbcType=INTEGER}, #{globalAclType,jdbcType=INTEGER}) and
not exists (select 1 from alf_node node where node.acl_id = acl.id) and
not exists (select 1 from alf_access_control_list inherited_acl where inherited_acl.inherited_acl = acl.id) and
not exists (select 1 from alf_access_control_list parent_acl where parent_acl.inherits_from = acl.id) and
not exists (
select 1
from alf_node_properties node_property
where node_property.long_value = acl.id and
node_property.qname_id in (
#{sharedAclToReplaceQNameId,jdbcType=BIGINT},
#{inheritFromAclQNameId,jdbcType=BIGINT}
)
)
order by
acl.id
</select>
<select id="select_IsAclUnused" parameterType="map" resultType="long">
select
acl.id
from
alf_access_control_list acl
where
acl.id = #{id,jdbcType=BIGINT} and
acl.type not in (#{fixedAclType,jdbcType=INTEGER}, #{globalAclType,jdbcType=INTEGER}) and
not exists (select 1 from alf_node node where node.acl_id = acl.id) and
not exists (select 1 from alf_access_control_list inherited_acl where inherited_acl.inherited_acl = acl.id) and
not exists (select 1 from alf_access_control_list parent_acl where parent_acl.inherits_from = acl.id) and
not exists (
select 1
from alf_node_properties node_property
where node_property.long_value = acl.id and
node_property.qname_id in (
#{sharedAclToReplaceQNameId,jdbcType=BIGINT},
#{inheritFromAclQNameId,jdbcType=BIGINT}
)
)
</select>
<select id="select_ADMNodesByAclId" parameterMap="parameter_IdMap" resultType="long"> <select id="select_ADMNodesByAclId" parameterMap="parameter_IdMap" resultType="long">
select select
id id
@@ -578,6 +624,15 @@
</foreach> </foreach>
</delete> </delete>
<delete id="delete_UnusedAce" parameterMap="parameter_IdMap">
delete
from
alf_access_control_entry
where
id = ? and
not exists (select 1 from alf_acl_member member where member.ace_id = alf_access_control_entry.id)
</delete>
<!-- for patch --> <!-- for patch -->
<delete id="delete_UnusedAces"> <delete id="delete_UnusedAces">
delete delete
@@ -632,6 +687,15 @@
id = ? id = ?
</delete> </delete>
<delete id="delete_UnusedAclChangeSet" parameterMap="parameter_IdMap">
delete
from
alf_acl_change_set
where
id = ? and
not exists (select 1 from alf_access_control_list acl where acl.acl_change_set = alf_acl_change_set.id)
</delete>
<delete id="delete_AceContext" parameterMap="parameter_IdMap"> <delete id="delete_AceContext" parameterMap="parameter_IdMap">
delete delete
from from
@@ -128,6 +128,13 @@
<property name="policyIgnoreUtil" ref="policyIgnoreUtil"/> <property name="policyIgnoreUtil" ref="policyIgnoreUtil"/>
</bean> </bean>
<bean id="unusedAclCleaner" class="org.alfresco.repo.domain.permissions.UnusedAclCleaner" init-method="init">
<property name="aclCrudDAO" ref="aclCrudDAO"/>
<property name="transactionService" ref="transactionService"/>
<property name="enabled" value="${system.aclCleanup.enabled}"/>
<property name="batchSize" value="${system.aclCleanup.batchSize}"/>
</bean>
<!-- =================== --> <!-- =================== -->
<!-- Dynamic Authorities --> <!-- Dynamic Authorities -->
<!-- =================== --> <!-- =================== -->
@@ -1141,6 +1141,11 @@ system.fixedACLsUpdater.maxItems=-1
# fixedACLsUpdater - Impose the order by in the query. If false, it may not process all the results but should do the queries faster # fixedACLsUpdater - Impose the order by in the query. If false, it may not process all the results but should do the queries faster
system.fixedACLsUpdater.orderNodes=true system.fixedACLsUpdater.orderNodes=true
# Unused ACL cleanup - enabled by default and limited to one batch per daily execution
system.aclCleanup.enabled=true
system.aclCleanup.cronExpression=0 0 2 * * ?
system.aclCleanup.batchSize=1000
cmis.disable.hidden.leading.period.files=false cmis.disable.hidden.leading.period.files=false
#Smart Folders Config Properties #Smart Folders Config Properties
@@ -27,6 +27,7 @@
<ref bean="upgradePasswordHashJobTrigger"/> <ref bean="upgradePasswordHashJobTrigger"/>
<ref bean="patchAddUnmovableAspectTrigger"/> <ref bean="patchAddUnmovableAspectTrigger"/>
<ref bean="fixedAclUpdaterTrigger"/> <ref bean="fixedAclUpdaterTrigger"/>
<ref bean="unusedAclCleanupTrigger"/>
</list> </list>
</property> </property>
</bean> </bean>
@@ -227,4 +228,21 @@
</property> </property>
</bean> </bean>
<bean id="unusedAclCleanupTrigger" class="org.springframework.scheduling.quartz.CronTriggerFactoryBean">
<property name="cronExpression" value="${system.aclCleanup.cronExpression}"/>
<property name="startDelay" value="${system.cronJob.startDelayMilliseconds}"/>
<property name="jobDetail">
<bean id="unusedAclCleanupJobDetail" class="org.springframework.scheduling.quartz.JobDetailFactoryBean">
<property name="jobClass" value="org.alfresco.repo.domain.permissions.UnusedAclCleanupJob"/>
<property name="jobDataAsMap">
<map>
<entry key="unusedAclCleaner" value-ref="unusedAclCleaner"/>
<entry key="jobLockService" value-ref="jobLockService"/>
<entry key="name" value="unusedAclCleanupJob"/>
</map>
</property>
</bean>
</property>
</bean>
</beans> </beans>
@@ -239,6 +239,8 @@ import org.alfresco.util.testing.category.NonBuildTests;
org.alfresco.repo.content.caching.CachingContentStoreTest.class, org.alfresco.repo.content.caching.CachingContentStoreTest.class,
org.alfresco.repo.content.caching.ContentCacheImplTest.class, org.alfresco.repo.content.caching.ContentCacheImplTest.class,
org.alfresco.repo.domain.permissions.FixedAclUpdaterUnitTest.class, org.alfresco.repo.domain.permissions.FixedAclUpdaterUnitTest.class,
org.alfresco.repo.domain.permissions.UnusedAclCleanerTest.class,
org.alfresco.repo.domain.permissions.UnusedAclCleanupJobTest.class,
org.alfresco.repo.domain.propval.PropertyTypeConverterTest.class, org.alfresco.repo.domain.propval.PropertyTypeConverterTest.class,
org.alfresco.repo.domain.schema.script.ScriptBundleExecutorImplTest.class, org.alfresco.repo.domain.schema.script.ScriptBundleExecutorImplTest.class,
org.alfresco.repo.search.MLAnaysisModeExpansionTest.class, org.alfresco.repo.search.MLAnaysisModeExpansionTest.class,
@@ -59,6 +59,7 @@ import org.alfresco.util.testing.category.NonBuildTests;
org.alfresco.repo.oauth1.OAuth1CredentialsStoreServiceTest.class, org.alfresco.repo.oauth1.OAuth1CredentialsStoreServiceTest.class,
org.alfresco.repo.oauth2.OAuth2CredentialsStoreServiceTest.class, org.alfresco.repo.oauth2.OAuth2CredentialsStoreServiceTest.class,
org.alfresco.repo.template.TemplateServiceImplTest.class, org.alfresco.repo.template.TemplateServiceImplTest.class,
org.alfresco.repo.template.TemplateNodeTest.class,
org.alfresco.repo.tenant.MultiTServiceImplTest.class, org.alfresco.repo.tenant.MultiTServiceImplTest.class,
org.alfresco.repo.search.SearcherComponentTest.class, org.alfresco.repo.search.SearcherComponentTest.class,
org.alfresco.repo.blog.BlogServiceImplTest.class, org.alfresco.repo.blog.BlogServiceImplTest.class,
@@ -26,17 +26,25 @@
package org.alfresco.repo.domain.permissions; package org.alfresco.repo.domain.permissions;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import junit.framework.TestCase; import junit.framework.TestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.categories.Category; import org.junit.experimental.categories.Category;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.alfresco.repo.security.permissions.ACEType;
import org.alfresco.repo.security.permissions.ACLType;
import org.alfresco.repo.security.permissions.PermissionReference; import org.alfresco.repo.security.permissions.PermissionReference;
import org.alfresco.repo.security.permissions.SimpleAccessControlListProperties;
import org.alfresco.repo.security.permissions.impl.SimplePermissionReference; import org.alfresco.repo.security.permissions.impl.SimplePermissionReference;
import org.alfresco.repo.transaction.RetryingTransactionHelper; import org.alfresco.repo.transaction.RetryingTransactionHelper;
import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback;
import org.alfresco.service.ServiceRegistry; import org.alfresco.service.ServiceRegistry;
import org.alfresco.service.cmr.security.AccessStatus;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName; import org.alfresco.service.namespace.QName;
import org.alfresco.service.transaction.TransactionService; import org.alfresco.service.transaction.TransactionService;
import org.alfresco.test_category.OwnJVMTestsCategory; import org.alfresco.test_category.OwnJVMTestsCategory;
@@ -57,7 +65,9 @@ public class AclCrudDAOTest extends TestCase
private TransactionService transactionService; private TransactionService transactionService;
private RetryingTransactionHelper txnHelper; private RetryingTransactionHelper txnHelper;
private AclCrudDAO aclCrudDAO; private AclCrudDAO aclCrudDAO;
private AclDAO aclDAO;
@Before
@Override @Override
public void setUp() throws Exception public void setUp() throws Exception
{ {
@@ -66,6 +76,75 @@ public class AclCrudDAOTest extends TestCase
txnHelper = transactionService.getRetryingTransactionHelper(); txnHelper = transactionService.getRetryingTransactionHelper();
aclCrudDAO = (AclCrudDAO) ctx.getBean("aclCrudDAO"); aclCrudDAO = (AclCrudDAO) ctx.getBean("aclCrudDAO");
aclDAO = (AclDAO) ctx.getBean("aclDAO");
}
@Test
public void testGetUnusedAclIdsReturnsBoundedIsolatedAcls() throws Exception
{
List<Long> aclIds = txnHelper.doInTransaction(() -> {
SimpleAccessControlListProperties properties = new SimpleAccessControlListProperties();
properties.setAclType(ACLType.DEFINING);
properties.setInherits(true);
properties.setVersioned(false);
Long definingAclId = aclDAO.createAccessControlList(properties).getId();
Long sharedAclId = aclDAO.getInheritedAccessControlList(definingAclId);
properties.setInherits(false);
properties.setAclType(ACLType.FIXED);
aclDAO.createAccessControlList(properties);
properties.setAclType(ACLType.GLOBAL);
aclDAO.createAccessControlList(properties);
properties.setAclType(ACLType.DEFINING);
Long firstUnusedAclId = aclDAO.createAccessControlList(properties).getId();
Long secondUnusedAclId = aclDAO.createAccessControlList(properties).getId();
return Arrays.asList(sharedAclId, firstUnusedAclId, secondUnusedAclId);
});
List<Long> unusedAclIds = txnHelper.doInTransaction(() -> aclCrudDAO.getUnusedAclIds(aclIds.get(0), 1), true);
assertEquals(1, unusedAclIds.size());
assertEquals(aclIds.get(1), unusedAclIds.get(0));
}
@Test
public void testDeleteUnusedAclPreservesSharedAceUntilLastReferenceIsRemoved() throws Exception
{
List<Long> ids = txnHelper.doInTransaction(() -> {
SimpleAccessControlListProperties properties = new SimpleAccessControlListProperties();
properties.setAclType(ACLType.DEFINING);
properties.setInherits(false);
properties.setVersioned(false);
Long firstAclId = aclDAO.createAccessControlList(properties).getId();
Long secondAclId = aclDAO.createAccessControlList(properties).getId();
Authority authority = aclCrudDAO.getOrCreateAuthority("acl-cleanup-test-user");
Permission permission = aclCrudDAO.getOrCreatePermission(
SimplePermissionReference.getPermissionReference(
QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "aclCleanupTest"), "Read"));
Ace ace = aclCrudDAO.getOrCreateAce(permission, authority, ACEType.ALL, AccessStatus.ALLOWED);
aclCrudDAO.addAclMembersToAcl(firstAclId, Arrays.asList(ace.getId()), 0);
aclCrudDAO.addAclMembersToAcl(secondAclId, Arrays.asList(ace.getId()), 0);
return Arrays.asList(firstAclId, secondAclId, ace.getId(), aclCrudDAO.getAcl(firstAclId).getAclChangeSetId());
});
assertTrue(txnHelper.doInTransaction(() -> aclCrudDAO.deleteUnusedAcl(ids.get(0))));
txnHelper.doInTransaction(() -> {
assertNull(aclCrudDAO.getAcl(ids.get(0)));
assertNotNull(aclCrudDAO.getAce(ids.get(2)));
assertNotNull(aclCrudDAO.getAclChangeSet(ids.get(3)));
return null;
}, true);
assertTrue(txnHelper.doInTransaction(() -> aclCrudDAO.deleteUnusedAcl(ids.get(1))));
txnHelper.doInTransaction(() -> {
assertNull(aclCrudDAO.getAcl(ids.get(1)));
assertNull(aclCrudDAO.getAce(ids.get(2)));
assertNull(aclCrudDAO.getAclChangeSet(ids.get(3)));
return null;
}, true);
} }
// TODO - alf_access_control_list, alf_acl_member, alf_access_control_entry // TODO - alf_access_control_list, alf_acl_member, alf_access_control_entry
@@ -0,0 +1,76 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2016 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.domain.permissions;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.Arrays;
import org.junit.Test;
import org.alfresco.service.transaction.TransactionService;
public class UnusedAclCleanerTest
{
private final AclCrudDAO aclCrudDAO = mock(AclCrudDAO.class);
@Test
public void cleanupBatchDeletesAtMostConfiguredCandidates()
{
UnusedAclCleaner cleaner = new UnusedAclCleaner();
cleaner.setAclCrudDAO(aclCrudDAO);
cleaner.setBatchSize(2);
when(aclCrudDAO.getUnusedAclIds(0, 2)).thenReturn(Arrays.asList(11L, 12L));
when(aclCrudDAO.deleteUnusedAcl(11L)).thenReturn(true);
when(aclCrudDAO.deleteUnusedAcl(12L)).thenReturn(false);
assertEquals(1, cleaner.cleanupBatch());
verify(aclCrudDAO).deleteUnusedAcl(11L);
verify(aclCrudDAO).deleteUnusedAcl(12L);
}
@Test
public void disabledCleanerDoesNothing()
{
UnusedAclCleaner cleaner = new UnusedAclCleaner();
cleaner.setAclCrudDAO(aclCrudDAO);
cleaner.setTransactionService(mock(TransactionService.class));
cleaner.setEnabled(false);
assertEquals(0, cleaner.execute());
verifyNoInteractions(aclCrudDAO);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsInvalidBatchSize()
{
new UnusedAclCleaner().setBatchSize(0);
}
}
@@ -0,0 +1,69 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2016 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.domain.permissions;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.Before;
import org.junit.Test;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.alfresco.error.AlfrescoRuntimeException;
public class UnusedAclCleanupJobTest
{
private final JobExecutionContext context = mock(JobExecutionContext.class);
private final UnusedAclCleaner cleaner = mock(UnusedAclCleaner.class);
private JobDetail jobDetail;
@Before
public void setUp()
{
jobDetail = JobBuilder.newJob(UnusedAclCleanupJob.class).withIdentity("unusedAclCleanupJob").build();
jobDetail.getJobDataMap().put("unusedAclCleaner", cleaner);
when(context.getJobDetail()).thenReturn(jobDetail);
}
@Test
public void delegatesCleanup() throws JobExecutionException
{
new UnusedAclCleanupJob().executeJob(context);
verify(cleaner).execute();
}
@Test(expected = AlfrescoRuntimeException.class)
public void rejectsMissingCleaner() throws JobExecutionException
{
jobDetail.getJobDataMap().remove("unusedAclCleaner");
new UnusedAclCleanupJob().executeJob(context);
}
}
@@ -0,0 +1,95 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.template;
import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.springframework.transaction.annotation.Transactional;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.service.ServiceRegistry;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.security.PermissionService;
import org.alfresco.test_category.BaseSpringTestsCategory;
import org.alfresco.util.BaseAlfrescoSpringTest;
import org.alfresco.util.GUID;
/**
* Test for {@link TemplateNode#getShareUrl()}.
* <p>
* A user with access to a child folder only (not its parent) must still be able to resolve a share URL for a node in that child folder, e.g. from a 'Send email' rule template.
*/
@Category(BaseSpringTestsCategory.class)
@Transactional
public class TemplateNodeTest extends BaseAlfrescoSpringTest
{
private PermissionService permissionService;
private ServiceRegistry serviceRegistry;
@Before
public void before() throws Exception
{
super.before();
this.permissionService = (PermissionService) this.applicationContext.getBean("permissionService");
this.serviceRegistry = (ServiceRegistry) this.applicationContext.getBean("ServiceRegistry");
}
@Test
public void testGetShareUrlWhenUserCannotReadParentFolder() throws Exception
{
String userName = "templateNodeTestUser" + GUID.generate();
createUser(userName);
// F1: not readable by userName
NodeRef f1 = createNode(this.rootNodeRef, "f1", ContentModel.TYPE_FOLDER);
this.permissionService.setInheritParentPermissions(f1, false);
// F2: userName only has access here, not on the parent F1
NodeRef f2 = createNode(f1, "f2", ContentModel.TYPE_FOLDER);
this.permissionService.setInheritParentPermissions(f2, false);
this.permissionService.setPermission(f2, userName, PermissionService.CONTRIBUTOR, true);
String shareUrl = AuthenticationUtil.runAs(() -> {
NodeRef doc = createNode(f2, "doc.txt", ContentModel.TYPE_CONTENT);
TemplateNode templateNode = new TemplateNode(doc, this.serviceRegistry, null);
try
{
return templateNode.getShareUrl();
}
catch (Exception e)
{
fail("getShareUrl() should not fail for a user without read access to a parent folder: " + e);
return null;
}
}, userName);
assertNotNull(shareUrl);
assertTrue(shareUrl.contains("document-details?nodeRef="));
}
}