diff --git a/src/backend/core/api/filters.py b/src/backend/core/api/filters.py index 82aac3b8..e796834c 100644 --- a/src/backend/core/api/filters.py +++ b/src/backend/core/api/filters.py @@ -47,10 +47,13 @@ class DocumentFilter(django_filters.FilterSet): title = AccentInsensitiveCharFilter( field_name="title", lookup_expr="unaccent__icontains", label=_("Title") ) + q = AccentInsensitiveCharFilter( + field_name="title", lookup_expr="unaccent__icontains", label=_("Search") + ) class Meta: model = models.Document - fields = ["title"] + fields = ["title", "q"] class ListDocumentFilter(DocumentFilter): @@ -70,7 +73,7 @@ class ListDocumentFilter(DocumentFilter): class Meta: model = models.Document - fields = ["is_creator_me", "is_favorite", "title"] + fields = ["is_creator_me", "is_favorite", "title", "q"] # pylint: disable=unused-argument def filter_is_creator_me(self, queryset, name, value): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 916fce31..0358260f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -72,7 +72,11 @@ from core.utils import ( ) from . import permissions, serializers, utils -from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter +from .filters import ( + DocumentFilter, + ListDocumentFilter, + UserSearchFilter, +) from .throttling import ( DocumentThrottle, UserListThrottleBurst, @@ -604,20 +608,18 @@ class DocumentViewSet( It performs early filtering on model fields, annotates user roles, and removes descendant documents to keep only the highest ancestors readable by the current user. """ - user = self.request.user + user = request.user # Not calling filter_queryset. We do our own cooking. queryset = self.get_queryset() - filterset = ListDocumentFilter( - self.request.GET, queryset=queryset, request=self.request - ) + filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request) if not filterset.is_valid(): raise drf.exceptions.ValidationError(filterset.errors) filter_data = filterset.form.cleaned_data # Filter as early as possible on fields that are available on the model - for field in ["is_creator_me", "title"]: + for field in ["is_creator_me", "title", "q"]: queryset = filterset.filters[field].filter(queryset, filter_data[field]) queryset = queryset.annotate_user_roles(user) @@ -1084,7 +1086,7 @@ class DocumentViewSet( filter_data = filterset.form.cleaned_data # Filter as early as possible on fields that are available on the model - for field in ["is_creator_me", "title"]: + for field in ["is_creator_me", "title", "q"]: queryset = filterset.filters[field].filter(queryset, filter_data[field]) queryset = queryset.annotate_user_roles(user) @@ -1107,7 +1109,11 @@ class DocumentViewSet( ordering=["path"], ) def descendants(self, request, *args, **kwargs): - """Handle listing descendants of a document""" + """Deprecated endpoint to list descendants of a document.""" + logger.warning( + "The 'descendants' endpoint is deprecated and will be removed in a future release. " + "The search endpoint should be used for all document retrieval use cases." + ) document = self.get_object() queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True) @@ -1397,25 +1403,25 @@ class DocumentViewSet( return duplicated_document - def _search_simple(self, request, text): + @drf.decorators.action(detail=False, methods=["get"], url_path="search") + @method_decorator(refresh_oidc_access_token) + def search(self, request, *args, **kwargs): """ - Returns a queryset filtered by the content of the document title + Returns an ordered list of documents best matching the search query parameter 'q'. + + It depends on a search configurable Search Indexer. If no Search Indexer is configured + or if it is not reachable, the function falls back to a basic title search. """ - # As the 'list' view we get a prefiltered queryset (deleted docs are excluded) - queryset = models.Document.objects.all() - filterset = DocumentFilter({"title": text}, queryset=queryset) + params = serializers.SearchDocumentSerializer(data=request.query_params) + params.is_valid(raise_exception=True) - if not filterset.is_valid(): - raise drf.exceptions.ValidationError(filterset.errors) - - queryset = filterset.filter_queryset(queryset) - - return self.get_response_for_queryset( - queryset.order_by("-updated_at"), - context={ - "request": request, - }, - ) + indexer = get_document_indexer() + if indexer: + return self._search_with_indexer(indexer, request, params=params) + except requests.exceptions.RequestException as e: + logger.error("Error while searching documents with indexer: %s", e) + # fallback on title search if the indexer is not reached + return self._title_search(request, params.validated_data, *args, **kwargs) @staticmethod def _search_with_indexer(indexer, request, params): @@ -1444,25 +1450,52 @@ class DocumentViewSet( } ) - @drf.decorators.action(detail=False, methods=["get"], url_path="search") - @method_decorator(refresh_oidc_access_token) - def search(self, request, *args, **kwargs): + def title_search(self, request, validated_data, *args, **kwargs): """ - Returns an ordered list of documents best matching the search query parameter 'q'. - - It depends on a search configurable Search Indexer. If no Search Indexer is configured or if it - is not reachable, the function falls back to a basic title search. + Fallback search method when no indexer is configured. + Only searches in the title field of documents. """ - params = serializers.SearchDocumentSerializer(data=request.query_params) - params.is_valid(raise_exception=True) + if not validated_data.get("path"): + return self.list(request, *args, **kwargs) - indexer = get_document_indexer() - if indexer: - return self._search_with_indexer(indexer, request, params=params) + return self._list_descendants(request, validated_data) - # The indexer is not configured, we fallback on a simple icontains filter by the - # model field 'title'. - return self._search_simple(request, text=params.validated_data["q"]) + def _list_descendants(self, request, validated_data): + """ + List all documents whose path starts with the provided path parameter. + Includes the parent document itself. + Used internally by the search endpoint when path filtering is requested. + """ + # Get parent document without access filtering + parent_path = validated_data["path"] + try: + parent = models.Document.objects.annotate_user_roles(request.user).get( + path=parent_path + ) + except models.Document.DoesNotExist as exc: + raise drf.exceptions.NotFound("Document not found from path.") from exc + + abilities = parent.get_abilities(request.user) + if not abilities.get("search"): + raise drf.exceptions.PermissionDenied( + "You do not have permission to search within this document." + ) + + # Get descendants and include the parent, ordered by path + queryset = ( + parent.get_descendants(include_self=True) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") + ) + queryset = self.filter_queryset(queryset) + + # filter by title + filterset = DocumentFilter(request.GET, queryset=queryset) + if not filterset.is_valid(): + raise drf.exceptions.ValidationError(filterset.errors) + + queryset = filterset.qs + return self.get_response_for_queryset(queryset) @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): diff --git a/src/backend/core/tasks/search.py b/src/backend/core/tasks/search.py index 4b30c6a7..e1c39e6b 100644 --- a/src/backend/core/tasks/search.py +++ b/src/backend/core/tasks/search.py @@ -63,7 +63,7 @@ def batch_document_indexer_task(timestamp): logger.info("Indexed %d documents", count) -def trigger_batch_document_indexer(item): +def trigger_batch_document_indexer(document): """ Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting. @@ -82,14 +82,14 @@ def trigger_batch_document_indexer(item): if batch_indexer_throttle_acquire(timeout=countdown): logger.info( "Add task for batch document indexation from updated_at=%s in %d seconds", - item.updated_at.isoformat(), + document.updated_at.isoformat(), countdown, ) batch_document_indexer_task.apply_async( - args=[item.updated_at], countdown=countdown + args=[document.updated_at], countdown=countdown ) else: - logger.info("Skip task for batch document %s indexation", item.pk) + logger.info("Skip task for batch document %s indexation", document.pk) else: - document_indexer_task.apply(args=[item.pk]) + document_indexer_task.apply(args=[document.pk]) diff --git a/src/backend/core/tests/documents/test_api_documents_list_filters.py b/src/backend/core/tests/documents/test_api_documents_list_filters.py index 5baa7e30..63710973 100644 --- a/src/backend/core/tests/documents/test_api_documents_list_filters.py +++ b/src/backend/core/tests/documents/test_api_documents_list_filters.py @@ -16,7 +16,16 @@ fake = Faker() pytestmark = pytest.mark.django_db -def test_api_documents_list_filter_and_access_rights(): +@pytest.mark.parametrize( + "title_search_field", + # for integration with indexer search we must have + # the same filtering behaviour with "q" and "title" parameters + [ + ("title"), + ("q"), + ], +) +def test_api_documents_list_filter_and_access_rights(title_search_field): """Filtering on querystring parameters should respect access rights.""" user = factories.UserFactory() client = APIClient() @@ -76,7 +85,7 @@ def test_api_documents_list_filter_and_access_rights(): filters = { "link_reach": random.choice([None, *models.LinkReachChoices.values]), - "title": random.choice([None, *word_list]), + title_search_field: random.choice([None, *word_list]), "favorite": random.choice([None, True, False]), "creator": random.choice([None, user, other_user]), "ordering": random.choice( diff --git a/src/backend/core/tests/documents/test_api_documents_search.py b/src/backend/core/tests/documents/test_api_documents_search.py index c6d0d8e3..a9ac2e47 100644 --- a/src/backend/core/tests/documents/test_api_documents_search.py +++ b/src/backend/core/tests/documents/test_api_documents_search.py @@ -85,29 +85,93 @@ def test_api_documents_search_endpoint_is_none(indexer_settings): "next": None, "previous": None, } - assert len(results) == 1 - assert results[0] == { - "id": str(document.id), - "abilities": document.get_abilities(user), - "ancestors_link_reach": None, - "ancestors_link_role": None, - "computed_link_reach": document.computed_link_reach, - "computed_link_role": document.computed_link_role, - "created_at": document.created_at.isoformat().replace("+00:00", "Z"), - "creator": str(document.creator.id), - "depth": 1, - "excerpt": document.excerpt, - "link_reach": document.link_reach, - "link_role": document.link_role, - "nb_accesses_ancestors": 1, - "nb_accesses_direct": 1, - "numchild": 0, - "path": document.path, - "title": document.title, - "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "deleted_at": None, - "user_role": access.role, + mock_list.return_value = drf_response.Response(mocked_response) + + q = "alpha" + response = client.get("/api/v1.0/documents/search/", data={"q": q}) + + assert mock_list.call_count == 1 + assert mock_list.call_args[0][0].GET.get("q") == q + assert response.json() == mocked_response + + +@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants") +def test_api_documents_search_fallback_on_search_list_sub_docs( + mock_list_descendants, indexer_settings +): + """ + When indexer is not configured and path parameter is provided, + should call _list_descendants() method + """ + indexer_settings.SEARCH_URL = "http://find/api/v1.0/search" + assert get_document_indexer() is not None + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(title="parent", users=[user]) + + mocked_response = { + "count": 0, + "next": None, + "previous": None, + "results": [{"title": "mocked _list_descendants result"}], } + mock_list_descendants.return_value = drf_response.Response(mocked_response) + + q = "alpha" + response = client.get( + "/api/v1.0/documents/search/", data={"q": q, "path": parent.path} + ) + + assert mock_list_descendants.call_count == 1 + assert mock_list_descendants.call_args[0][0].GET.get("q") == q + assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path + assert response.json() == mocked_response + + +@mock.patch("core.api.viewsets.DocumentViewSet._title_search") +def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings): + """ + When indexer is configured but crashes -> falls back on title_search + """ + # indexer is properly configured + indexer_settings.SEARCH_URL = None + assert get_document_indexer() is None + # but returns an error when the query is sent + responses.add( + responses.POST, + "http://find/api/v1.0/search", + json=[{"error": "Some indexer error"}], + status=404, + ) + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + mocked_response = { + "count": 0, + "next": None, + "previous": None, + "results": [{"title": "mocked title_search result"}], + } + mock_title_search.return_value = drf_response.Response(mocked_response) + + parent = factories.DocumentFactory(title="parent", users=[user]) + q = "alpha" + response = client.get( + "/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path} + ) + + # the search endpoint did not crash + assert response.status_code == 200 + # fallback on title_search + assert mock_title_search.call_count == 1 + assert mock_title_search.call_args[0][0].GET.get("q") == q + assert mock_title_search.call_args[0][0].GET.get("path") == parent.path + assert response.json() == mocked_response @responses.activate diff --git a/src/backend/core/tests/documents/test_api_documents_search_descendants.py b/src/backend/core/tests/documents/test_api_documents_search_descendants.py new file mode 100644 index 00000000..a97321bb --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_search_descendants.py @@ -0,0 +1,956 @@ +""" +Tests for search API endpoint in impress's core app when indexer is not +available and a path param is given. +""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api.filters import remove_accents + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def disable_indexer(indexer_settings): + """Disable search indexer for all tests in this file.""" + indexer_settings.SEARCH_INDEXER_CLASS = None + + +def test_api_documents_search_descendants_list_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve the descendants of a public document.""" + document = factories.DocumentFactory(link_reach="public", title="doc parent") + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, title="doc child" + ) + grand_child = factories.DocumentFactory(parent=child1, title="doc grand child") + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get( + "/api/v1.0/documents/search/", data={"q": "doc", "path": document.path} + ) + + assert response.status_code == 200 + assert response.json() == { + "count": 4, + "next": None, + "previous": None, + "results": [ + { + # the search should include the parent document itself + "abilities": document.get_abilities(AnonymousUser()), + "ancestors_link_role": None, + "ancestors_link_reach": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "deleted_at": None, + "depth": 1, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": child1.get_abilities(AnonymousUser()), + "ancestors_link_reach": document.link_reach, + "ancestors_link_role": document.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "ancestors_link_reach": document.link_reach, + "ancestors_link_role": document.link_role + if (child1.link_reach == "public" and child1.link_role == "editor") + else document.link_role, + "computed_link_reach": "public", + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "ancestors_link_reach": document.link_reach, + "ancestors_link_role": document.link_role, + "computed_link_reach": "public", + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + ], + } + + +def test_api_documents_search_descendants_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the descendants of a document who + has a public ancestor. + """ + grand_parent = factories.DocumentFactory( + link_reach="public", title="grand parent doc" + ) + parent = factories.DocumentFactory( + parent=grand_parent, + link_reach=random.choice(["authenticated", "restricted"]), + title="parent doc", + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), + parent=parent, + title="document", + ) + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, title="child doc" + ) + grand_child = factories.DocumentFactory(parent=child1, title="grand child doc") + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get( + "/api/v1.0/documents/search/", data={"q": "doc", "path": document.path} + ) + + assert response.status_code == 200 + assert response.json() == { + "count": 4, + "next": None, + "previous": None, + "results": [ + { + # the search should include the parent document itself + "abilities": document.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "deleted_at": None, + "depth": 3, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": child1.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": "public", + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": "public", + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + ], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_search_descendants_list_anonymous_restricted_or_authenticated( + reach, +): + """ + Anonymous users should not be able to retrieve descendants of a document that is not public. + """ + document = factories.DocumentFactory(title="parent", link_reach=reach) + child = factories.DocumentFactory(title="child", parent=document) + _grand_child = factories.DocumentFactory(title="grand child", parent=child) + + response = APIClient().get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to search within this document." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_search_descendants_list_authenticated_unrelated_public_or_authenticated( + reach, +): + """ + Authenticated users should be able to retrieve the descendants of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach, title="parent") + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, link_reach="restricted", title="child" + ) + grand_child = factories.DocumentFactory(parent=child1, title="grand child") + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": child2.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + ], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_search_descendants_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", title="parent" + ) + document = factories.DocumentFactory( + link_reach="restricted", parent=parent, title="document" + ) + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, link_reach="restricted", title="child" + ) + grand_child = factories.DocumentFactory(parent=child1, title="grand child") + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + { + "abilities": child2.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": None, + }, + ], + } + + +def test_api_documents_search_descendants_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the descendants of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted", title="parent") + child1, _child2 = factories.DocumentFactory.create_batch( + 2, parent=document, title="child" + ) + _grand_child = factories.DocumentFactory(parent=child1, title="grand child") + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to search within this document." + } + + +def test_api_documents_search_descendants_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the descendants of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(title="parent") + access = factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, title="child" + ) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1, title="grand child") + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": access.role, + }, + { + "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": grand_child.ancestors_link_reach, + "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 3, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": access.role, + }, + { + "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": access.role, + }, + ], + } + + +def test_api_documents_search_descendants_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the descendants of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent") + grand_parent_access = factories.UserDocumentAccessFactory( + document=grand_parent, user=user + ) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", title="parent" + ) + document = factories.DocumentFactory( + parent=parent, link_reach="restricted", title="document" + ) + + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, title="child" + ) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1, title="grand child") + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": grand_parent_access.role, + }, + { + "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": grand_child.ancestors_link_reach, + "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": grand_parent_access.role, + }, + { + "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": grand_parent_access.role, + }, + ], + } + + +def test_api_documents_search_descendants_list_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve all the descendants of a document + as a result of being related to one of its children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "doc", "path": document.path} + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to search within this document." + } + + +def test_api_documents_search_descendants_list_authenticated_related_team_none( + mock_user_teams, +): + """ + Authenticated users should not be able to retrieve the descendants of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted", title="document") + factories.DocumentFactory.create_batch(2, parent=document, title="child") + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "doc", "path": document.path} + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to search within this document." + } + + +def test_api_documents_search_descendants_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted", title="parent") + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, title="child" + ) + grand_child = factories.DocumentFactory(parent=child1, title="grand child") + + access = factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get( + "/api/v1.0/documents/search/", data={"q": "child", "path": document.path} + ) + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": access.role, + }, + { + "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": grand_child.ancestors_link_reach, + "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "deleted_at": None, + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": access.role, + }, + { + "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "deleted_at": None, + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 1, + "nb_accesses_direct": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_role": access.role, + }, + ], + } + + +@pytest.mark.parametrize( + "query,nb_results", + [ + ("", 7), # Empty string + ("Project Alpha", 1), # Exact match + ("project", 2), # Partial match (case-insensitive) + ("Guide", 2), # Word match within a title + ("Special", 0), # No match (nonexistent keyword) + ("2024", 2), # Match by numeric keyword + ("velo", 1), # Accent-insensitive match (velo vs vélo) + ("bêta", 1), # Accent-insensitive match (bêta vs beta) + ], +) +def test_api_documents_search_descendants_search_on_title(query, nb_results): + """Authenticated users should be able to search documents by their unaccented title.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(users=[user]) + + # Create documents with predefined titles + titles = [ + "Project Alpha Documentation", + "Project Beta Overview", + "User Guide", + "Financial Report 2024", + "Annual Review 2024", + "Guide du vélo urbain", # <-- Title with accent for accent-insensitive test + ] + for title in titles: + factories.DocumentFactory(title=title, parent=parent) + + # Perform the search query + response = client.get( + "/api/v1.0/documents/search/", data={"q": query, "path": parent.path} + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == nb_results + + # Ensure all results contain the query in their title + for result in results: + assert ( + remove_accents(query).lower().strip() + in remove_accents(result["title"]).lower() + )