diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5143fc055..c518e1fe54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -628,6 +628,18 @@ jobs: pom-dir: tas-webdav - test-name: "Integration TAS tests" 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: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v18.20.0 @@ -645,10 +657,12 @@ jobs: run: | bash ./scripts/ci/init.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" timeout-minutes: ${{ fromJSON(env.GITHUB_ACTIONS_DEPLOY_TIMEOUT) }} 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" - name: "Run tests" id: tests diff --git a/.secrets.baseline b/.secrets.baseline index b9b905a3bc..ed77ef8e17 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -353,6 +353,40 @@ "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": [ { "type": "Secret Keyword", @@ -691,7 +725,7 @@ "filename": "remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java", "hashed_secret": "4ffa743f28fc949c19e65ab045d7906d0609a2c2", "is_verified": false, - "line_number": 268, + "line_number": 277, "is_secret": false } ], @@ -1868,5 +1902,5 @@ } ] }, - "generated_at": "2026-08-17T14:05:27Z" + "generated_at": "2026-09-01T06:39:16Z" } diff --git a/amps/ags/pom.xml b/amps/ags/pom.xml index d7db265707..91c7ff5216 100644 --- a/amps/ags/pom.xml +++ b/amps/ags/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo-amps - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/ags/rm-automation/pom.xml b/amps/ags/rm-automation/pom.xml index 2ab3855512..1746333acc 100644 --- a/amps/ags/rm-automation/pom.xml +++ b/amps/ags/rm-automation/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-governance-services-community-parent - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml b/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml index a2f3ee9a9f..5d85ee4a31 100644 --- a/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml +++ b/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-governance-services-automation-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/ags/rm-community/pom.xml b/amps/ags/rm-community/pom.xml index b3038c9abd..8829ef80ec 100644 --- a/amps/ags/rm-community/pom.xml +++ b/amps/ags/rm-community/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-governance-services-community-parent - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/ags/rm-community/rm-community-repo/.env b/amps/ags/rm-community/rm-community-repo/.env index c81cb186bd..95c40ae6b6 100644 --- a/amps/ags/rm-community/rm-community-repo/.env +++ b/amps/ags/rm-community/rm-community-repo/.env @@ -1,3 +1,3 @@ -SOLR6_TAG=2.0.22-A.1 +SOLR6_TAG=2.0.22 POSTGRES_TAG=17.9 ACTIVEMQ_TAG=6.2.0-jre17-rockylinux8 diff --git a/amps/ags/rm-community/rm-community-repo/Dockerfile b/amps/ags/rm-community/rm-community-repo/Dockerfile index e00c30b734..2318010d39 100644 --- a/amps/ags/rm-community/rm-community-repo/Dockerfile +++ b/amps/ags/rm-community/rm-community-repo/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE # BUILD STAGE AGS -FROM debian:11-slim AS AGSBUILDER +FROM debian:12-slim AS AGSBUILDER RUN export DEBIAN_FRONTEND=noninteractive; \ apt-get update -qqy && apt-get -yqq install unzip && \ diff --git a/amps/ags/rm-community/rm-community-repo/pom.xml b/amps/ags/rm-community/rm-community-repo/pom.xml index b9668a5425..a62a05e603 100644 --- a/amps/ags/rm-community/rm-community-repo/pom.xml +++ b/amps/ags/rm-community/rm-community-repo/pom.xml @@ -8,7 +8,7 @@ org.alfresco alfresco-governance-services-community-repo-parent - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/ags/rm-community/rm-community-rest-api-explorer/pom.xml b/amps/ags/rm-community/rm-community-rest-api-explorer/pom.xml index 1bde57191c..cbbbeccc07 100644 --- a/amps/ags/rm-community/rm-community-rest-api-explorer/pom.xml +++ b/amps/ags/rm-community/rm-community-rest-api-explorer/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-governance-services-community-repo-parent - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/pom.xml b/amps/pom.xml index fa6270b932..f44c748f89 100644 --- a/amps/pom.xml +++ b/amps/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/amps/share-services/pom.xml b/amps/share-services/pom.xml index ae7ae93ce6..f89b401a8d 100644 --- a/amps/share-services/pom.xml +++ b/amps/share-services/pom.xml @@ -8,7 +8,7 @@ org.alfresco alfresco-community-repo-amps - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/core/pom.xml b/core/pom.xml index 6d2b2be94e..41a26644b2 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/data-model/pom.xml b/data-model/pom.xml index feebf8d542..02201cda82 100644 --- a/data-model/pom.xml +++ b/data-model/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/mmt/pom.xml b/mmt/pom.xml index b76f08f1f8..e13f44bb72 100644 --- a/mmt/pom.xml +++ b/mmt/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/distribution/pom.xml b/packaging/distribution/pom.xml index 40f4720045..6e99b09705 100644 --- a/packaging/distribution/pom.xml +++ b/packaging/distribution/pom.xml @@ -9,6 +9,6 @@ org.alfresco alfresco-community-repo-packaging - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/docker-alfresco/pom.xml b/packaging/docker-alfresco/pom.xml index 0ed1bc9016..d4aeb7e211 100644 --- a/packaging/docker-alfresco/pom.xml +++ b/packaging/docker-alfresco/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo-packaging - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/pom.xml b/packaging/pom.xml index fb456bc674..f53a449a60 100644 --- a/packaging/pom.xml +++ b/packaging/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/tests/environment/.env b/packaging/tests/environment/.env index c81cb186bd..ea791ef103 100644 --- a/packaging/tests/environment/.env +++ b/packaging/tests/environment/.env @@ -1,3 +1,5 @@ -SOLR6_TAG=2.0.22-A.1 +SOLR6_TAG=2.0.22 POSTGRES_TAG=17.9 ACTIVEMQ_TAG=6.2.0-jre17-rockylinux8 +ELASTICSEARCH_TAG=8.17.10 +BATCH_INDEXING_TAG=5.9.0-A.1 diff --git a/packaging/tests/environment/docker-compose-minimal+batch-indexing.yml b/packaging/tests/environment/docker-compose-minimal+batch-indexing.yml new file mode 100644 index 0000000000..6cab082942 --- /dev/null +++ b/packaging/tests/environment/docker-compose-minimal+batch-indexing.yml @@ -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 \ No newline at end of file diff --git a/packaging/tests/pom.xml b/packaging/tests/pom.xml index f67975151f..8927735ba7 100644 --- a/packaging/tests/pom.xml +++ b/packaging/tests/pom.xml @@ -6,7 +6,7 @@ org.alfresco alfresco-community-repo-packaging - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/tests/tas-cmis/pom.xml b/packaging/tests/tas-cmis/pom.xml index 53f2d9c104..9795e3931e 100644 --- a/packaging/tests/tas-cmis/pom.xml +++ b/packaging/tests/tas-cmis/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo-tests - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/tests/tas-email/pom.xml b/packaging/tests/tas-email/pom.xml index e71e77a4f8..2f858fb296 100644 --- a/packaging/tests/tas-email/pom.xml +++ b/packaging/tests/tas-email/pom.xml @@ -9,7 +9,7 @@ org.alfresco alfresco-community-repo-tests - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/tests/tas-integration/pom.xml b/packaging/tests/tas-integration/pom.xml index 27a6cd4ad7..271e4956a6 100644 --- a/packaging/tests/tas-integration/pom.xml +++ b/packaging/tests/tas-integration/pom.xml @@ -9,7 +9,7 @@ org.alfresco alfresco-community-repo-tests - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/tests/tas-restapi/pom.xml b/packaging/tests/tas-restapi/pom.xml index 34298a670a..63215b2a96 100644 --- a/packaging/tests/tas-restapi/pom.xml +++ b/packaging/tests/tas-restapi/pom.xml @@ -8,7 +8,7 @@ org.alfresco alfresco-community-repo-tests - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT @@ -42,9 +42,21 @@ - elasticsearch-e2e-tests + elasticsearch-e2e-tests-part1 - ${project.basedir}/src/test/resources/elasticsearch-e2e-suite.xml + ${project.basedir}/src/test/resources/test-suites/elasticsearch-e2e-part1-suite.xml + + + + elasticsearch-e2e-tests-part2 + + ${project.basedir}/src/test/resources/test-suites/elasticsearch-e2e-part2-suite.xml + + + + elasticsearch-e2e-tests-part3 + + ${project.basedir}/src/test/resources/test-suites/elasticsearch-e2e-part3-suite.xml diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/DocumentLibraryTagFilterTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/DocumentLibraryTagFilterTest.java index 3d2ee1d577..cbbe9cf4c7 100644 --- a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/DocumentLibraryTagFilterTest.java +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/DocumentLibraryTagFilterTest.java @@ -28,60 +28,54 @@ package org.alfresco.rest.search; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; 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.testng.annotations.BeforeClass; import org.testng.annotations.Test; -import org.alfresco.rest.core.RestRequest; -import org.alfresco.utility.Utility; +import org.alfresco.rest.model.RestTagModel; import org.alfresco.utility.data.RandomData; import org.alfresco.utility.model.FileModel; 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. *

- * 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:""} 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. *

- * 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"}) 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 spaceTaggedFile; + private String singleWordTagNodeRef; + private String spaceTagNodeRef; + @BeforeClass(alwaysRun = true) public void dataPreparation() { // 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(); - singleWordTag = "single" + unique; - spaceTag = "long " + unique; // contains a space - the scenario that used to fail + String singleWordTag = "single" + unique; + String spaceTag = "long " + unique; // contains a space - the scenario that used to fail - singleWordTaggedFile = createTaggedFile(singleWordTag); - spaceTaggedFile = createTaggedFile(spaceTag); + singleWordTaggedFile = createFile(); + singleWordTagNodeRef = tagFileAndGetTagNodeRef(singleWordTaggedFile, singleWordTag); - // Wait until both tags resolve through the doclist endpoint (category node + cm:taggable both indexed). - assertTrue(waitForTagFilter(singleWordTag, singleWordTaggedFile.getName()), + spaceTaggedFile = createFile(); + 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); - assertTrue(waitForTagFilter(spaceTag, spaceTaggedFile.getName()), + assertTrue(waitForTagFilter(spaceTagNodeRef, spaceTaggedFile.getName()), "Space-containing tag was not indexed/searchable in time: " + spaceTag); } @@ -89,78 +83,78 @@ public class DocumentLibraryTagFilterTest extends AbstractE2EFunctionalTest @Test 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. */ @Test public void tagFilterWithSingleWordTagReturnsOnlyTheTaggedDocument() { - assertTagFilterReturnsExactly(singleWordTag, singleWordTaggedFile.getName(), spaceTaggedFile.getName()); + assertTagFilterReturnsExactly(singleWordTagNodeRef, singleWordTaggedFile.getName(), spaceTaggedFile.getName()); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- - /** Creates a text document in the test site's document library and tags it via the public v1 REST API. */ - private FileModel createTaggedFile(String tag) + /** Creates a text document in the test site's document library. */ + private FileModel createFile() { FileModel file = FileModel.getRandomFileModel(FileType.TEXT_PLAIN, "MNT-25799 tag filter test content"); dataContent.usingUser(testUser).usingSite(testSite).createContent(file); - - restClient.authenticateUser(testUser).withCoreAPI().usingResource(file).addTag(tag); - restClient.assertStatusCodeIs(HttpStatus.CREATED); return file; } - /** Runs the tag filter and asserts it returns exactly the expected file and never the other (unrelated) file. */ - private void assertTagFilterReturnsExactly(String tag, String expectedFileName, String excludedFileName) + /** Tags the file via the public v1 REST API and returns the created tag category nodeRef. */ + private String tagFileAndGetTagNodeRef(FileModel file, String tag) { - JsonPath json = tagFilter(tag); - restClient.assertStatusCodeIs(HttpStatus.OK); - - List 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); + RestTagModel tagModel = restClient.authenticateUser(testUser).withCoreAPI().usingResource(file).addTag(tag); + restClient.assertStatusCodeIs(HttpStatus.CREATED); + return "workspace://SpacesStore/" + tagModel.getId(); } - /** Polls the doclist tag filter until {@code expectedFileName} appears or the retry budget is exhausted. */ - private boolean waitForTagFilter(String tag, String expectedFileName) + /** Runs the tag filter and asserts it returns exactly the expected file and never the other (unrelated) file. */ + private void assertTagFilterReturnsExactly(String tagNodeRef, String expectedFileName, String excludedFileName) { - for (int attempt = 0; attempt < SEARCH_MAX_ATTEMPTS; attempt++) - { - JsonPath json = tagFilter(tag); - if (String.valueOf(HttpStatus.OK.value()).equals(restClient.getStatusCode())) - { - List fileNames = json.getList("items.location.file"); - if (fileNames != null && fileNames.contains(expectedFileName)) - { - return true; - } - } - Utility.waitToLoopTime(properties.getSolrWaitTimeInSeconds(), - "Waiting for tag to be indexed. Attempt: " + (attempt + 1)); - } - return false; + SearchResponse response = tagFilter(tagNodeRef); + restClient.assertStatusCodeIs(HttpStatus.OK); + + List fileNames = resultFileNames(response); + assertTrue(fileNames.contains(expectedFileName), + "Tag filter '" + tagNodeRef + "' did not return the tagged document '" + expectedFileName + "'. Got: " + fileNames); + assertFalse(fileNames.contains(excludedFileName), + "Tag filter '" + tagNodeRef + "' incorrectly returned an unrelated document '" + excludedFileName + "'. Got: " + fileNames); + assertEquals(fileNames.size(), 1, + "Tag filter '" + tagNodeRef + "' returned an unexpected number of documents. Got: " + fileNames); + } + + /** Polls the Search API tag filter until {@code expectedFileName} appears or the retry budget is exhausted. */ + private boolean waitForTagFilter(String tagNodeRef, String expectedFileName) + { + 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=}. + * Builds the AFTS query that matches documents carrying the given tag. + *

+ * 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); - restClient.configureRequestSpec().setBasePath(DOCLIST_BASE_PATH); + return "+=cm\\:taggable:\"" + tagNodeRef + "\""; + } - RestRequest request = RestRequest.simpleRequest(HttpMethod.GET, - "all/site/{site}/{container}?filter=tag&filterData={filterData}", - testSite.getId(), DOCUMENT_LIBRARY, tag); - return restClient.process(request).getResponse().jsonPath(); + /** Extracts the {@code cm:name} of every document returned by a search response. */ + private List resultFileNames(SearchResponse response) + { + return response.getEntries().stream() + .map(entry -> entry.getModel().getName()) + .collect(Collectors.toList()); } } diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchAdvancedQueryOperatorsTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchAdvancedQueryOperatorsTest.java new file mode 100644 index 0000000000..11911fb9d0 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchAdvancedQueryOperatorsTest.java @@ -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 . + * #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"); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchCategoriesTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchCategoriesTest.java new file mode 100644 index 0000000000..11a2acb160 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchCategoriesTest.java @@ -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 . + * #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"); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchCmisQueriesTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchCmisQueriesTest.java new file mode 100644 index 0000000000..37aff9d2e9 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchCmisQueriesTest.java @@ -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 . + * #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%'"); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchComplexAclTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchComplexAclTest.java new file mode 100644 index 0000000000..2577534803 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchComplexAclTest.java @@ -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 . + * #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"); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchDeepFolderHierarchyTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchDeepFolderHierarchyTest.java new file mode 100644 index 0000000000..8925d13836 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchDeepFolderHierarchyTest.java @@ -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 . + * #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()); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchMultiLanguageTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchMultiLanguageTest.java new file mode 100644 index 0000000000..2631f1ac53 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchMultiLanguageTest.java @@ -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 . + * #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()); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchSecondaryAssociationAdvancedTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchSecondaryAssociationAdvancedTest.java new file mode 100644 index 0000000000..76d76c8ef5 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchSecondaryAssociationAdvancedTest.java @@ -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 . + * #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)); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchTagsTest.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchTagsTest.java new file mode 100644 index 0000000000..b541a6f893 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/search/SearchTagsTest.java @@ -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 . + * #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"); + } +} diff --git a/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part1-suite.xml b/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part1-suite.xml new file mode 100644 index 0000000000..b6b294e9bf --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part1-suite.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packaging/tests/tas-restapi/src/test/resources/elasticsearch-e2e-suite.xml b/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part2-suite.xml similarity index 50% rename from packaging/tests/tas-restapi/src/test/resources/elasticsearch-e2e-suite.xml rename to packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part2-suite.xml index fc5b30c26b..2696c08105 100644 --- a/packaging/tests/tas-restapi/src/test/resources/elasticsearch-e2e-suite.xml +++ b/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part2-suite.xml @@ -1,63 +1,75 @@ - + - - + - - + + - + + + - + + - + + - + + + + + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -66,73 +78,19 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part3-suite.xml b/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part3-suite.xml new file mode 100644 index 0000000000..669d8a7a89 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/resources/test-suites/elasticsearch-e2e-part3-suite.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packaging/tests/tas-restapi/src/test/resources/test-suites/part2-suite.xml b/packaging/tests/tas-restapi/src/test/resources/test-suites/part2-suite.xml index 14e5417245..83754e0600 100644 --- a/packaging/tests/tas-restapi/src/test/resources/test-suites/part2-suite.xml +++ b/packaging/tests/tas-restapi/src/test/resources/test-suites/part2-suite.xml @@ -20,10 +20,11 @@ + diff --git a/packaging/tests/tas-webdav/pom.xml b/packaging/tests/tas-webdav/pom.xml index 3bea1c3c30..f9ceae73f0 100644 --- a/packaging/tests/tas-webdav/pom.xml +++ b/packaging/tests/tas-webdav/pom.xml @@ -9,7 +9,7 @@ org.alfresco alfresco-community-repo-tests - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/packaging/war/pom.xml b/packaging/war/pom.xml index eaf3dfb1d9..f2c8f6b470 100644 --- a/packaging/war/pom.xml +++ b/packaging/war/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo-packaging - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/pom.xml b/pom.xml index cd8c58449e..80da3e4c9c 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT pom Alfresco Community Repo Parent @@ -51,20 +51,21 @@ 8.0.1 5.23.0 5.23.0 - 5.4.4-A.6 - 4.4.4-A.7 + 5.4.4 + 4.4.4 7.1 1.1.0-A.1 1.9.22.1 7.0.8 7.0.6 + 1.16.6 3.5.3 2.22.1 4.1.7 1.0.0-jakarta-1 10.2 - 1.84 + 1.85 5.18.0 1.18 3.27.3 @@ -75,9 +76,9 @@ 33.3.1-jre 4.5.14 4.4.16 - 5.5 - 5.3.4 - 5.3.4 + 5.6.4 + 5.4.3 + 5.4.3 3.1-HTTPCLIENT-1265 2.12.2 26.84.0 @@ -93,7 +94,7 @@ 3.5.0.Final 4.18.3 4.1.137.Final - 6.2.4 + 6.2.7 1.27.1 4.2.2 @@ -123,9 +124,9 @@ 1.1.8 2.9.0 2.5.2 - 5.0.0-A2 + 5.0.0 3.4.2-A.3 - 26.2.0 + 26.3.0-A.4 2.2.0 2.4.0 @@ -1055,6 +1056,13 @@ pom import + + io.micrometer + micrometer-bom + ${dependency.micrometer.version} + pom + import + @@ -1207,7 +1215,7 @@ jakarta.xml.soap:jakarta.xml.soap-api:(, 2.0.1) jakarta.jws:jakarta.jws-api:(, 3.0.0) - org.bouncycastle:(,1.84) + org.bouncycastle:(,1.85) com.sun.xml.bind diff --git a/remote-api/pom.xml b/remote-api/pom.xml index 78f29a56c1..adbaca4995 100644 --- a/remote-api/pom.xml +++ b/remote-api/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/remote-api/src/main/resources/alfresco/templates/publicapi/org/alfresco/api/DiscoveryApiWebscript.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/publicapi/org/alfresco/api/DiscoveryApiWebscript.get.desc.xml index bcadf83ba9..051a5d257c 100644 --- a/remote-api/src/main/resources/alfresco/templates/publicapi/org/alfresco/api/DiscoveryApiWebscript.get.desc.xml +++ b/remote-api/src/main/resources/alfresco/templates/publicapi/org/alfresco/api/DiscoveryApiWebscript.get.desc.xml @@ -4,7 +4,7 @@ Returns repository information /discovery user - required + required argument public_api \ No newline at end of file diff --git a/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml b/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml index 1f5b354797..851e66c9aa 100644 --- a/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml +++ b/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml @@ -228,6 +228,7 @@ AdminConsole AdminConsoleHelper + Bulk Filesystem Import diff --git a/remote-api/src/test/java/org/alfresco/AppContext02TestSuite.java b/remote-api/src/test/java/org/alfresco/AppContext02TestSuite.java index bee7079a15..6607070566 100644 --- a/remote-api/src/test/java/org/alfresco/AppContext02TestSuite.java +++ b/remote-api/src/test/java/org/alfresco/AppContext02TestSuite.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2021 Alfresco Software Limited + * 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 @@ -49,6 +49,7 @@ import org.alfresco.util.testing.category.NonBuildTests; org.alfresco.rest.api.tests.ActivitiesPostingTest.class, org.alfresco.rest.api.tests.AuthenticationsTest.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.tests.GroupsTest.class, org.alfresco.rest.api.tests.ModulePackagesApiTest.class, diff --git a/remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java b/remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java index 243f6294e1..d461e9fe86 100644 --- a/remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java +++ b/remote-api/src/test/java/org/alfresco/repo/web/scripts/servlet/RemoteAuthenticatorFactoryAdminConsoleAccessTest.java @@ -221,6 +221,15 @@ public class RemoteAuthenticatorFactoryAdminConsoleAccessTest extends BaseSpring complexCheckOfScriptCases(families); } + @Test + public void testAdminCanAccessBulkFilesystemImportScript() + { + Set 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 */ diff --git a/remote-api/src/test/java/org/alfresco/rest/api/tests/DiscoveryInReadOnlyModeTest.java b/remote-api/src/test/java/org/alfresco/rest/api/tests/DiscoveryInReadOnlyModeTest.java new file mode 100644 index 0000000000..f899a5dc08 --- /dev/null +++ b/remote-api/src/test/java/org/alfresco/rest/api/tests/DiscoveryInReadOnlyModeTest.java @@ -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 . + * #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; + } + } +} diff --git a/repository/pom.xml b/repository/pom.xml index 99db7ea255..42ac445933 100644 --- a/repository/pom.xml +++ b/repository/pom.xml @@ -7,7 +7,7 @@ org.alfresco alfresco-community-repo - 26.3.0.36-SNAPSHOT + 26.3.0.50-SNAPSHOT diff --git a/repository/src/main/java/org/alfresco/repo/domain/permissions/AbstractAclCrudDAOImpl.java b/repository/src/main/java/org/alfresco/repo/domain/permissions/AbstractAclCrudDAOImpl.java index b4836eaca6..8374fb74c0 100644 --- a/repository/src/main/java/org/alfresco/repo/domain/permissions/AbstractAclCrudDAOImpl.java +++ b/repository/src/main/java/org/alfresco/repo/domain/permissions/AbstractAclCrudDAOImpl.java @@ -35,6 +35,7 @@ import org.springframework.dao.ConcurrencyFailureException; import org.springframework.extensions.surf.util.ParameterCheck; import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.model.ContentModel; import org.alfresco.repo.cache.SimpleCache; import org.alfresco.repo.cache.TransactionalCache; 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.qname.QNameDAO; 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.impl.SimplePermissionReference; import org.alfresco.service.cmr.security.AccessStatus; @@ -252,6 +254,64 @@ public abstract class AbstractAclCrudDAOImpl implements AclCrudDAO return getLatestAclEntityByGuid(aclGuid); } + @Override + public List 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 members = getAclMembersByAcl(aclEntityId); + List 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 qnamePair = qnameDAO.getQName(qname); + return qnamePair == null ? null : qnamePair.getFirst(); + } + public List getADMNodesByAcl(long aclEntityId, int maxResults) { return getADMNodeEntityIdsByAcl(aclEntityId, maxResults); @@ -373,12 +433,20 @@ public abstract class AbstractAclCrudDAOImpl implements AclCrudDAO protected abstract Long getLatestAclEntityByGuid(String aclGuid); + protected abstract List 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 updateAceEntity(AceEntity updatedAceEntity); protected abstract int deleteAclEntity(long id); + protected abstract int deleteAclChangeSetEntityIfUnused(long aclChangeSetEntityId); + protected abstract List getADMNodeEntityIdsByAcl(long aclEntityId, int maxResults); // @@ -681,6 +749,8 @@ public abstract class AbstractAclCrudDAOImpl implements AclCrudDAO protected abstract int deleteAceEntities(List aceIds); + protected abstract int deleteAceEntityIfUnused(long aceId); + // // Permission // diff --git a/repository/src/main/java/org/alfresco/repo/domain/permissions/AclCrudDAO.java b/repository/src/main/java/org/alfresco/repo/domain/permissions/AclCrudDAO.java index 07c840ceac..3d5c969b7d 100644 --- a/repository/src/main/java/org/alfresco/repo/domain/permissions/AclCrudDAO.java +++ b/repository/src/main/java/org/alfresco/repo/domain/permissions/AclCrudDAO.java @@ -63,6 +63,10 @@ public interface AclCrudDAO public Long getLatestAclByGuid(String aclGuid); + List getUnusedAclIds(long afterAclId, int maxResults); + + boolean deleteUnusedAcl(long aclEntityId); + public void updateAcl(AclUpdateEntity entity); public void deleteAcl(long aclEntityId); diff --git a/repository/src/main/java/org/alfresco/repo/domain/permissions/UnusedAclCleaner.java b/repository/src/main/java/org/alfresco/repo/domain/permissions/UnusedAclCleaner.java new file mode 100644 index 0000000000..0be3662e7a --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/domain/permissions/UnusedAclCleaner.java @@ -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 . + * #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 aclIds = aclCrudDAO.getUnusedAclIds(0, batchSize); + int deleted = 0; + for (Long aclId : aclIds) + { + if (aclCrudDAO.deleteUnusedAcl(aclId)) + { + deleted++; + } + } + return deleted; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/domain/permissions/UnusedAclCleanupJob.java b/repository/src/main/java/org/alfresco/repo/domain/permissions/UnusedAclCleanupJob.java new file mode 100644 index 0000000000..40afb53f9e --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/domain/permissions/UnusedAclCleanupJob.java @@ -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 . + * #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(); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/domain/permissions/ibatis/AclCrudDAOImpl.java b/repository/src/main/java/org/alfresco/repo/domain/permissions/ibatis/AclCrudDAOImpl.java index 78969b741d..91aa08f693 100644 --- a/repository/src/main/java/org/alfresco/repo/domain/permissions/ibatis/AclCrudDAOImpl.java +++ b/repository/src/main/java/org/alfresco/repo/domain/permissions/ibatis/AclCrudDAOImpl.java @@ -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_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_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 UPDATE_ACL = "alfresco.permissions.update_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 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_UNUSED_ACL_CHANGESET = "alfresco.permissions.delete_UnusedAclChangeSet"; private static final String INSERT_ACE = "alfresco.permissions.insert.insert_Ace"; 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_ACE_WITH_NO_CONTEXT = "alfresco.permissions.select_AceWithNoContext"; 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 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); } + @Override + protected List getUnusedAclEntityIds(long afterAclId, Long sharedAclToReplaceQNameId, Long inheritFromAclQNameId, + int fixedAclType, int globalAclType, int maxResults) + { + Map 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 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") @Override protected List getADMNodeEntityIdsByAcl(long aclEntityId, int maxResults) @@ -284,6 +315,14 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl return template.delete(DELETE_ACL_CHANGESET, params); } + @Override + protected int deleteAclChangeSetEntityIfUnused(long aclChangeSetEntityId) + { + Map params = new HashMap<>(1); + params.put("id", aclChangeSetEntityId); + return template.delete(DELETE_UNUSED_ACL_CHANGESET, params); + } + @Override protected int updateChangeSetEntity(Long id, long commitTimeMs) { @@ -361,6 +400,14 @@ public class AclCrudDAOImpl extends AbstractAclCrudDAOImpl return template.delete(DELETE_ACES_LIST, aceEntityIds); } + @Override + protected int deleteAceEntityIfUnused(long aceEntityId) + { + Map params = new HashMap<>(1); + params.put("id", aceEntityId); + return template.delete(DELETE_UNUSED_ACE, params); + } + @Override protected long createAceContextEntity(AceContextEntity entity) { diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java index 18fe5843a6..c3af2b1526 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java @@ -427,6 +427,11 @@ public class ElasticsearchHttpClientFactory 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()); // Build and set the HTTP/2 response timeout using the configured responseTimeout property diff --git a/repository/src/main/java/org/alfresco/repo/template/TemplateNode.java b/repository/src/main/java/org/alfresco/repo/template/TemplateNode.java index 2a4297f632..e0ba3229ba 100644 --- a/repository/src/main/java/org/alfresco/repo/template/TemplateNode.java +++ b/repository/src/main/java/org/alfresco/repo/template/TemplateNode.java @@ -658,7 +658,14 @@ public class TemplateNode extends BasePermissionsNode implements NamespacePrefix // TODO URLs for the repo server. // 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()); @@ -679,6 +686,7 @@ public class TemplateNode extends BasePermissionsNode implements NamespacePrefix // ------------------------------------------------------------------------------ // Inner classes + @Override public NamespacePrefixResolver getNamespacePrefixResolver() { return this.services.getNamespaceService(); diff --git a/repository/src/main/resources/alfresco/ibatis/org.alfresco.repo.domain.dialect.Dialect/permissions-common-SqlMap.xml b/repository/src/main/resources/alfresco/ibatis/org.alfresco.repo.domain.dialect.Dialect/permissions-common-SqlMap.xml index 3d166426ac..bf9e256354 100644 --- a/repository/src/main/resources/alfresco/ibatis/org.alfresco.repo.domain.dialect.Dialect/permissions-common-SqlMap.xml +++ b/repository/src/main/resources/alfresco/ibatis/org.alfresco.repo.domain.dialect.Dialect/permissions-common-SqlMap.xml @@ -384,6 +384,52 @@ where acl.acl_id = ? and acl.latest = ? + + + +