From 13276c4b945bc5571c6c54fd3a4faf7de84a5036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 28 Sep 2026 15:54:26 +0200 Subject: [PATCH] wip --- src/backend/core/api/serializers.py | 101 ++++-- src/backend/core/api/viewsets.py | 8 +- src/backend/core/models.py | 82 +++-- .../documents/test_api_document_accesses.py | 6 + .../test_api_document_accesses_create.py | 10 + .../tests/documents/test_api_documents_all.py | 5 + .../test_api_documents_children_list.py | 71 ++++- .../test_api_documents_content_update.py | 115 +++++++ ...st_api_documents_encrypted_restrictions.py | 167 ++++++++++ .../test_api_documents_favorite_list.py | 5 + .../documents/test_api_documents_list.py | 29 +- .../documents/test_api_documents_retrieve.py | 47 ++- .../documents/test_api_documents_search.py | 60 +++- .../test_api_documents_search_descendants.py | 221 +++++++++++++ .../documents/test_api_documents_trashbin.py | 23 +- .../documents/test_api_documents_tree.py | 149 ++++++++- src/backend/core/tests/test_api_users.py | 2 + .../tests/test_models_document_accesses.py | 20 ++ .../core/tests/test_models_documents.py | 22 ++ .../tests/test_services_search_indexers.py | 2 +- src/backend/core/tests/utils/encryption.py | 16 + src/backend/impress/settings.py | 4 + .../doc-collaboration/encryptedWebsocket.ts | 48 ++- .../custom-blocks/AccessibleImageBlock.tsx | 301 ++++++++++++++++++ .../hook/__tests__/useSaveDoc.test.tsx | 45 +++ .../docs/doc-editor/hook/useSaveDoc.tsx | 30 +- .../components/PendingEncryptionSection.tsx | 12 - .../apps/impress/src/i18n/translations.json | 1 - .../__tests__/collaborationWSHandler.test.ts | 73 ++++- .../y-provider/__tests__/hocuspocusWS.test.ts | 258 +-------------- 30 files changed, 1562 insertions(+), 371 deletions(-) create mode 100644 src/backend/core/tests/documents/test_api_documents_encrypted_restrictions.py create mode 100644 src/backend/core/tests/utils/encryption.py create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 75866f11f..8783e225d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -8,6 +8,7 @@ from os.path import splitext from django.conf import settings from django.db.models import Q +from django.db.models.manager import BaseManager from django.utils.functional import lazy from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -32,7 +33,7 @@ class UserSerializer(serializers.ModelSerializer): full_name = serializers.SerializerMethodField(read_only=True) short_name = serializers.SerializerMethodField(read_only=True) - suite_user_id = serializers.CharField(source='sub', read_only=True) + suite_user_id = serializers.CharField(source="sub", read_only=True) class Meta: model = models.User @@ -76,8 +77,38 @@ class UserLightSerializer(UserSerializer): class Meta: model = models.User - fields = ["id", "full_name", "short_name"] - read_only_fields = ["id", "full_name", "short_name"] + fields = ["full_name", "short_name"] + read_only_fields = ["full_name", "short_name"] + + +ENCRYPTION_ACCESSES_ATTRIBUTE = "_encryption_accesses" + + +class DocumentListSerializer(serializers.ListSerializer): + """ + Precompute, in one query for the whole list, the encryption data that the + document serializers would otherwise query once per document. + """ + + def to_representation(self, data): + documents = list(data.all() if isinstance(data, BaseManager) else data) + request = self.context.get("request") + if request and request.user.is_authenticated and documents: + # Search results nest their top parent, serialized with the same context + parents = [ + parent + for parent in ( + document.__dict__.get("parent") for document in documents + ) + if isinstance(parent, models.Document) + ] + targets = documents + parents + mapping = models.Document.get_encryption_accesses_mapping( + {document.id for document in targets}, request.user + ) + for document in targets: + setattr(document, ENCRYPTION_ACCESSES_ATTRIBUTE, mapping[document.id]) + return super().to_representation(documents) class ListDocumentSerializer(serializers.ModelSerializer): @@ -101,6 +132,7 @@ class ListDocumentSerializer(serializers.ModelSerializer): class Meta: model = models.Document + list_serializer_class = DocumentListSerializer fields = [ "id", "abilities", @@ -188,43 +220,54 @@ class ListDocumentSerializer(serializers.ModelSerializer): """Return the deleted_at of the current document.""" return instance.ancestors_deleted_at - def get_accesses_user_ids(self, instance): - """Return user IDs of members with access to this document. - The frontend uses these to fetch public keys from the encryption service.""" + def _get_encryption_accesses(self, instance): + """ + Return the encryption data of the direct user accesses of the document + for the current user, or None for anonymous users. List serializers + compute it in bulk for the whole page; a single document computes it + once, in one query shared by all the encryption fields. + """ request = self.context.get("request") if not request or not request.user.is_authenticated: return None - return [str(uid) for uid in instance.accesses_user_ids] + data = getattr(instance, ENCRYPTION_ACCESSES_ATTRIBUTE, None) + if data is None: + data = models.Document.get_encryption_accesses_mapping( + [instance.id], request.user + )[instance.id] + setattr(instance, ENCRYPTION_ACCESSES_ATTRIBUTE, data) + return data + + def get_accesses_user_ids(self, instance): + """Return the suite user ids (subs) of the members with a direct access. + The frontend uses these to fetch public keys from the encryption service.""" + data = self._get_encryption_accesses(instance) + if data is None: + return None + return sorted(str(sub) for sub in data["user_subs"]) def get_accesses_versions_per_user(self, instance): """Return versions of users' public keys at share time.""" - request = self.context.get("request") - if not request or not request.user.is_authenticated: - return None if not instance.is_encrypted: return None - return instance.accesses_versions_per_user + data = self._get_encryption_accesses(instance) + if data is None: + return None + return data["versions"] def get_encrypted_document_symmetric_key_for_user(self, instance): """Return the encrypted symmetric key for the current user.""" - request = self.context.get("request") - if not request or not request.user.is_authenticated: - return None if not instance.is_encrypted: return None - try: - access = models.DocumentAccess.objects.get( - document=instance, user=request.user - ) - return access.encrypted_document_symmetric_key_for_user - except models.DocumentAccess.DoesNotExist: + data = self._get_encryption_accesses(instance) + if data is None: return None + return data["own_key"] def get_is_pending_encryption_for_user(self, instance): """True when the current user has a DocumentAccess row on this - encrypted document with no wrapped key — i.e. they were added - to the access list but haven't completed their encryption - onboarding yet. + encrypted document with no wrapped key: they were added to the + access list but haven't completed their encryption onboarding yet. Clients use this to avoid attempting to decrypt (which would fail with a meaningless key error) and render a "waiting for @@ -232,14 +275,10 @@ class ListDocumentSerializer(serializers.ModelSerializer): """ if not instance.is_encrypted: return False - request = self.context.get("request") - if not request or not request.user.is_authenticated: + data = self._get_encryption_accesses(instance) + if data is None: return False - return models.DocumentAccess.objects.filter( - document=instance, - user=request.user, - encrypted_document_symmetric_key_for_user__isnull=True, - ).exists() + return data["has_own_access"] and data["own_key"] is None class DocumentLightSerializer(serializers.ModelSerializer): @@ -261,6 +300,7 @@ class DocumentSerializer(ListDocumentSerializer): class Meta: model = models.Document + list_serializer_class = DocumentListSerializer fields = [ "id", "abilities", @@ -391,6 +431,7 @@ class SearchDocumentSerializer(ListDocumentSerializer): class Meta: model = models.Document + list_serializer_class = DocumentListSerializer fields = ListDocumentSerializer.Meta.fields + ["parent"] read_only_fields = ListDocumentSerializer.Meta.read_only_fields + ["parent"] diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8dc821262..73370b29f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -667,7 +667,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", "q"]: + for field in ["is_creator_me", "is_encrypted", "title", "q"]: queryset = filterset.filters[field].filter(queryset, filter_data[field]) queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user) @@ -1217,7 +1217,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", "q"]: + for field in ["is_creator_me", "is_encrypted", "title", "q"]: queryset = filterset.filters[field].filter(queryset, filter_data[field]) queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user) @@ -3041,15 +3041,19 @@ class DocumentAccessViewSet( "created_at", "role", "team", + "encrypted_document_symmetric_key_for_user", + "encryption_public_key_version", "user__id", "user__short_name", "user__full_name", "user__email", "user__language", "user__is_first_connection", + "user__sub", "document__id", "document__path", "document__depth", + "document__is_encrypted", ) resource_field_name = "document" throttle_scope = "document_access" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d2ca78b15..9eb53c966 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1341,38 +1341,64 @@ class Document(MP_Node, BaseModel): """Actual link role on the document.""" return self.computed_link_definition["link_role"] - @property - def accesses_user_ids(self): + @staticmethod + def get_encryption_accesses_mapping(document_ids, user): """ - Return the list of user IDs with access to this document. - The frontend uses these IDs to fetch public keys from the - centralized encryption service. - """ - return list( - DocumentAccess.objects - .filter(document=self, user__isnull=False) - .values_list('user__sub', flat=True) - .distinct() - ) + Collect, in one query, the per-user encryption data of the direct user + accesses of several documents, as seen by `user`: - @property - def accesses_versions_per_user(self): - """ - Return the version of each user's public key at the time of sharing. - This allows the frontend to detect key changes by comparing the - version stored at share time with the current public key version. - """ - accesses = ( - DocumentAccess.objects - .filter(document=self, user__isnull=False, encryption_public_key_version__isnull=False) - .values_list('user__sub', 'encryption_public_key_version') - ) + - `user_subs`: subs of the users with a direct access (the frontend + fetches their public keys from the encryption service), + - `versions`: public key version each user was shared with, by sub, + - `has_own_access` / `own_key`: whether `user` has a direct access and + the symmetric key wrapped for them (None while pending). - return { - str(sub): version - for sub, version in accesses - if version is not None + Only the wrapped key of `user` is selected, never the other users' keys. + Returns a dict keyed by document id with an entry for every given id. + """ + mapping = { + document_id: { + "user_subs": [], + "versions": {}, + "has_own_access": False, + "own_key": None, + } + for document_id in document_ids } + if not mapping: + return mapping + + user_id = user.id if user.is_authenticated else None + rows = ( + DocumentAccess.objects.filter(document_id__in=mapping, user__isnull=False) + .annotate( + own_key=models.Case( + models.When( + user_id=user_id, + then=models.F("encrypted_document_symmetric_key_for_user"), + ), + default=models.Value(None), + output_field=models.TextField(), + ) + ) + .order_by() + .values_list( + "document_id", + "user_id", + "user__sub", + "encryption_public_key_version", + "own_key", + ) + ) + for document_id, access_user_id, sub, version, own_key in rows: + data = mapping[document_id] + data["user_subs"].append(sub) + if version is not None: + data["versions"][str(sub)] = version + if user_id is not None and access_user_id == user_id: + data["has_own_access"] = True + data["own_key"] = own_key + return mapping def get_abilities(self, user): # pylint: disable=too-many-locals """ diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 83c4b392f..ee2a71977 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -175,6 +175,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( "max_role": access.role, "abilities": { "destroy": False, + "encryption_key": False, "partial_update": False, "retrieve": False, "set_role_to": [], @@ -271,6 +272,7 @@ def test_api_document_accesses_list_authenticated_related_privileged( "full_name": access.user.full_name, "short_name": access.user.short_name, "is_first_connection": access.user.is_first_connection, + "suite_user_id": access.user.sub, } if access.user else None @@ -280,6 +282,8 @@ def test_api_document_accesses_list_authenticated_related_privileged( "team": access.team, "role": access.role, "abilities": access.get_abilities(user), + "encryption_public_key_version": None, + "is_pending_encryption": False, } for access in ancestors_accesses + document_accesses ], @@ -646,6 +650,8 @@ def test_api_document_accesses_retrieve_authenticated_related( "max_ancestors_role": None, "max_role": access.role, "abilities": access.get_abilities(user), + "encryption_public_key_version": None, + "is_pending_encryption": False, } diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index 95192b4a1..f66fcf84c 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -194,6 +194,8 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_user( "path": new_document_access.document.path, }, "id": str(new_document_access.id), + "encryption_public_key_version": None, + "is_pending_encryption": False, "max_ancestors_role": None, "max_role": role, "role": role, @@ -301,6 +303,8 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_team( "path": new_document_access.document.path, }, "id": str(new_document_access.id), + "encryption_public_key_version": None, + "is_pending_encryption": False, "max_ancestors_role": None, "max_role": role, "role": role, @@ -382,6 +386,8 @@ def test_api_document_accesses_create_authenticated_owner_share_to_user( "path": new_document_access.document.path, }, "id": str(new_document_access.id), + "encryption_public_key_version": None, + "is_pending_encryption": False, "max_ancestors_role": None, "max_role": role, "role": role, @@ -472,6 +478,8 @@ def test_api_document_accesses_create_authenticated_owner_share_to_team( "depth": new_document_access.document.depth, }, "id": str(new_document_access.id), + "encryption_public_key_version": None, + "is_pending_encryption": False, "max_ancestors_role": None, "max_role": role, "role": role, @@ -550,6 +558,8 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user "depth": new_document_access.document.depth, }, "id": str(new_document_access.id), + "encryption_public_key_version": None, + "is_pending_encryption": False, "max_ancestors_role": None, "max_role": role, "role": role, diff --git a/src/backend/core/tests/documents/test_api_documents_all.py b/src/backend/core/tests/documents/test_api_documents_all.py index 5900d280d..390cc7ef5 100644 --- a/src/backend/core/tests/documents/test_api_documents_all.py +++ b/src/backend/core/tests/documents/test_api_documents_all.py @@ -14,6 +14,7 @@ import pytest from rest_framework.test import APIClient from core import factories, models +from core.tests.utils.encryption import direct_user_subs pytestmark = pytest.mark.django_db @@ -350,6 +351,10 @@ def test_api_documents_all_format(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 4b45d3b96..d4ec36d41 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -10,6 +10,7 @@ import pytest from rest_framework.test import APIClient from core import factories +from core.tests.utils.encryption import direct_user_subs pytestmark = pytest.mark.django_db @@ -44,6 +45,10 @@ def test_api_documents_children_list_anonymous_public_standalone( "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -68,6 +73,10 @@ def test_api_documents_children_list_anonymous_public_standalone( "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, @@ -122,6 +131,10 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -146,6 +159,10 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, @@ -195,9 +212,9 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - with django_assert_num_queries(9): + with django_assert_num_queries(10): client.get(f"/api/v1.0/documents/{document.id!s}/children/") - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get( f"/api/v1.0/documents/{document.id!s}/children/", ) @@ -219,6 +236,10 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -243,6 +264,10 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, @@ -279,10 +304,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - with django_assert_num_queries(10): + with django_assert_num_queries(11): client.get(f"/api/v1.0/documents/{document.id!s}/children/") - with django_assert_num_queries(6): + with django_assert_num_queries(7): response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") assert response.status_code == 200 @@ -302,6 +327,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -326,6 +355,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, @@ -389,7 +422,7 @@ def test_api_documents_children_list_authenticated_related_direct( child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - with django_assert_num_queries(9): + with django_assert_num_queries(10): response = client.get( f"/api/v1.0/documents/{document.id!s}/children/", ) @@ -412,6 +445,10 @@ def test_api_documents_children_list_authenticated_related_direct( "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -436,6 +473,10 @@ def test_api_documents_children_list_authenticated_related_direct( "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, @@ -476,7 +517,7 @@ def test_api_documents_children_list_authenticated_related_parent( document=grand_parent, user=user ) - with django_assert_num_queries(10): + with django_assert_num_queries(11): response = client.get( f"/api/v1.0/documents/{document.id!s}/children/", ) @@ -498,6 +539,10 @@ def test_api_documents_children_list_authenticated_related_parent( "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -522,6 +567,10 @@ def test_api_documents_children_list_authenticated_related_parent( "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, @@ -615,7 +664,7 @@ def test_api_documents_children_list_authenticated_related_team_members( access = factories.TeamDocumentAccessFactory(document=document, team="myteam") - with django_assert_num_queries(9): + with django_assert_num_queries(10): response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") # pylint: disable=R0801 @@ -636,6 +685,10 @@ def test_api_documents_children_list_authenticated_related_team_members( "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "is_encrypted": child1.is_encrypted, @@ -660,6 +713,10 @@ def test_api_documents_children_list_authenticated_related_team_members( "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "is_encrypted": child2.is_encrypted, diff --git a/src/backend/core/tests/documents/test_api_documents_content_update.py b/src/backend/core/tests/documents/test_api_documents_content_update.py index b7b876147..81360f251 100644 --- a/src/backend/core/tests/documents/test_api_documents_content_update.py +++ b/src/backend/core/tests/documents/test_api_documents_content_update.py @@ -585,3 +585,118 @@ def test_api_documents_content_upadte_invalid_yjs_doc(): ) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# Encryption state of the content + + +def _editor_client(is_encrypted): + """Return a client logged in as an editor of a new document, and the document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", is_encrypted=is_encrypted + ) + factories.UserDocumentAccessFactory(document=document, user=user, role="editor") + client = APIClient() + client.force_login(user) + return client, document + + +def test_api_documents_content_update_plain_content_encrypted_false(): + """A plain document accepts content flagged as not encrypted.""" + client, document = _editor_client(is_encrypted=False) + + response = client.patch( + f"/api/v1.0/documents/{document.id!s}/content/", + {"content": get_sample_ydoc(), "contentEncrypted": False, "websocket": True}, + format="json", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert get_s3_content(document) == get_sample_ydoc() + + +def test_api_documents_content_update_plain_content_encrypted_true(): + """ + Encrypted content is refused on a plain document (e.g. decrypted meanwhile by + someone else), and the stored content is left untouched. + """ + client, document = _editor_client(is_encrypted=False) + ciphertext = base64.b64encode(b"ciphertext").decode("utf-8") + + response = client.patch( + f"/api/v1.0/documents/{document.id!s}/content/", + {"content": ciphertext, "contentEncrypted": True, "websocket": True}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "contentEncrypted": ( + "Content encryption status does not match the document's current state. " + "Please refresh and try again." + ) + } + assert get_s3_content(document) == factories.YDOC_HELLO_WORLD_BASE64 + + +def test_api_documents_content_update_encrypted_content_encrypted_true(): + """ + An encrypted document accepts ciphertext flagged as encrypted. It is stored as + is, without being parsed as a Yjs update, and its attachments are kept. + """ + client, document = _editor_client(is_encrypted=True) + document.attachments = [f"{document.id!s}/attachments/encrypted.enc"] + document.save() + ciphertext = base64.b64encode(b"not a yjs update").decode("utf-8") + + response = client.patch( + f"/api/v1.0/documents/{document.id!s}/content/", + {"content": ciphertext, "contentEncrypted": True, "websocket": True}, + format="json", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert get_s3_content(document) == ciphertext + document.refresh_from_db() + assert document.attachments == [f"{document.id!s}/attachments/encrypted.enc"] + + +def test_api_documents_content_update_encrypted_content_encrypted_false(): + """ + Plain content is refused on an encrypted document (e.g. encrypted meanwhile by + someone else), so it never overwrites the ciphertext. + """ + client, document = _editor_client(is_encrypted=True) + + response = client.patch( + f"/api/v1.0/documents/{document.id!s}/content/", + {"content": get_sample_ydoc(), "contentEncrypted": False, "websocket": True}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "contentEncrypted": ( + "Content encryption status does not match the document's current state. " + "Please refresh and try again." + ) + } + assert get_s3_content(document) == factories.YDOC_HELLO_WORLD_BASE64 + + +def test_api_documents_content_update_encrypted_content_encrypted_missing(): + """The encryption flag is required to save the content of an encrypted document.""" + client, document = _editor_client(is_encrypted=True) + + response = client.patch( + f"/api/v1.0/documents/{document.id!s}/content/", + {"content": get_sample_ydoc(), "websocket": True}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "contentEncrypted": "Required when the document is encrypted." + } + assert get_s3_content(document) == factories.YDOC_HELLO_WORLD_BASE64 diff --git a/src/backend/core/tests/documents/test_api_documents_encrypted_restrictions.py b/src/backend/core/tests/documents/test_api_documents_encrypted_restrictions.py new file mode 100644 index 000000000..028d9c2f0 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_encrypted_restrictions.py @@ -0,0 +1,167 @@ +""" +Server-side features that need the plaintext of a document (comments, AI, +conversion, duplication) are refused on encrypted documents, even for users +who would be allowed to use them on a plain document. +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="ai_settings") +def fixture_ai_settings(settings): + """Enable every AI feature so that only the encryption can refuse the request.""" + settings.AI_FEATURE_ENABLED = True + settings.AI_FEATURE_BLOCKNOTE_ENABLED = True + settings.AI_FEATURE_LEGACY_ENABLED = True + settings.AI_ALLOW_REACH_FROM = "restricted" + settings.AI_MODEL = "llama" + settings.OPENAI_SDK_BASE_URL = "http://localhost-ai:12345/" + settings.OPENAI_SDK_API_KEY = "test-key" + + +def _owner_client(is_encrypted): + """Return a client logged in as the owner of a new document, and the document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[(user, models.RoleChoices.OWNER)], + is_encrypted=is_encrypted, + ) + client = APIClient() + client.force_login(user) + return client, user, document + + +@pytest.mark.parametrize( + "ability", + [ + "ai_proxy", + "ai_transform", + "ai_translate", + "comment", + "duplicate", + "formatted_content", + ], +) +@pytest.mark.usefixtures("ai_settings") +def test_api_documents_encrypted_abilities_plaintext_features(ability): + """The plaintext features are granted on a plain document and refused once encrypted.""" + _client, user, plain_document = _owner_client(is_encrypted=False) + encrypted_document = factories.DocumentFactory( + link_reach="restricted", + users=[(user, models.RoleChoices.OWNER)], + is_encrypted=True, + ) + + assert plain_document.get_abilities(user)[ability] is True + assert encrypted_document.get_abilities(user)[ability] is False + + +def test_api_documents_encrypted_abilities_encryption_management(): + """Owners keep the right to manage the encryption of an encrypted document.""" + _client, user, document = _owner_client(is_encrypted=True) + + abilities = document.get_abilities(user) + + assert abilities["encrypt"] is True + assert abilities["remove_encryption"] is True + assert abilities["content_patch"] is True + assert abilities["content_retrieve"] is True + + +def test_api_documents_encrypted_threads_create_refused(): + """Threads cannot be created on an encrypted document.""" + client, _user, document = _owner_client(is_encrypted=True) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", {"body": "test"} + ) + + assert response.status_code == 403 + assert not models.Thread.objects.filter(document=document).exists() + + +def test_api_documents_encrypted_threads_list_refused(): + """Threads of an encrypted document cannot be listed.""" + client, _user, document = _owner_client(is_encrypted=True) + factories.ThreadFactory(document=document) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/threads/") + + assert response.status_code == 403 + + +def test_api_documents_encrypted_comments_create_refused(): + """Comments cannot be added to a thread of an encrypted document.""" + client, _user, document = _owner_client(is_encrypted=True) + thread = factories.ThreadFactory(document=document) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, + ) + + assert response.status_code == 403 + assert not models.Comment.objects.filter(thread=thread).exists() + + +def test_api_documents_encrypted_comments_list_refused(): + """Comments of a thread of an encrypted document cannot be listed.""" + client, _user, document = _owner_client(is_encrypted=True) + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) + + assert response.status_code == 403 + + +def test_api_documents_encrypted_duplicate_refused(): + """An encrypted document cannot be duplicated, its content is ciphertext.""" + client, _user, document = _owner_client(is_encrypted=True) + + response = client.post(f"/api/v1.0/documents/{document.id!s}/duplicate/") + + assert response.status_code == 403 + assert models.Document.objects.count() == 1 + + +def test_api_documents_encrypted_formatted_content_refused(): + """An encrypted document cannot be converted server-side.""" + client, _user, document = _owner_client(is_encrypted=True) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/formatted-content/", + {"content_format": "markdown"}, + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "url_path, payload", + [ + ("ai-proxy", {"messages": [{"role": "user", "content": "Hello"}]}), + ("ai-transform", {"text": "Hello", "action": "prompt"}), + ("ai-translate", {"text": "Hello", "language": "es"}), + ], +) +@pytest.mark.usefixtures("ai_settings") +def test_api_documents_encrypted_ai_refused(url_path, payload): + """AI features are refused on an encrypted document, even when enabled.""" + client, _user, document = _owner_client(is_encrypted=True) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/{url_path}/", + payload, + format="json", + ) + + assert response.status_code == 403 diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index ae985c674..a34056e9a 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -8,6 +8,7 @@ import pytest from rest_framework.test import APIClient from core import factories, models +from core.tests.utils.encryption import direct_user_subs pytestmark = pytest.mark.django_db @@ -72,6 +73,10 @@ def test_api_document_favorite_list_authenticated_with_favorite(): "deleted_at": None, "depth": document.depth, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": True, "is_encrypted": document.is_encrypted, diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 4619b066a..519b9a61f 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -42,8 +42,9 @@ def test_api_documents_list_format(): client.force_login(user) other_users = factories.UserFactory.create_batch(3) + members = factories.UserFactory.create_batch(2) document = factories.DocumentFactory( - users=factories.UserFactory.create_batch(2), + users=members, favorited_by=[user, *other_users], link_traces=other_users, ) @@ -63,6 +64,8 @@ def test_api_documents_list_format(): assert results[0] == { "id": str(document.id), "abilities": document.get_abilities(user), + "accesses_user_ids": sorted(str(member.sub) for member in [*members, user]), + "accesses_versions_per_user": None, "ancestors_link_reach": None, "ancestors_link_role": None, "computed_link_reach": document.computed_link_reach, @@ -71,9 +74,11 @@ def test_api_documents_list_format(): "creator": str(document.creator.id), "deleted_at": None, "depth": 1, + "encrypted_document_symmetric_key_for_user": None, "excerpt": document.excerpt, "is_favorite": True, "is_encrypted": document.is_encrypted, + "is_pending_encryption_for_user": False, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 3, @@ -154,11 +159,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries): str(child4_with_access.id), } - with django_assert_num_queries(14): + with django_assert_num_queries(15): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached - with django_assert_num_queries(6): + with django_assert_num_queries(7): response = client.get("/api/v1.0/documents/") assert response.status_code == 200 @@ -192,11 +197,11 @@ def test_api_documents_list_authenticated_via_team( expected_ids = {str(document.id) for document in documents_team1 + documents_team2} - with django_assert_num_queries(14): + with django_assert_num_queries(15): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get("/api/v1.0/documents/") assert response.status_code == 200 @@ -225,11 +230,11 @@ def test_api_documents_list_authenticated_link_reach_restricted( other_document = factories.DocumentFactory(link_reach="public") models.LinkTrace.objects.create(document=other_document, user=user) - with django_assert_num_queries(6): + with django_assert_num_queries(7): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get("/api/v1.0/documents/") assert response.status_code == 200 @@ -274,11 +279,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated( expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)} - with django_assert_num_queries(11): + with django_assert_num_queries(12): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get("/api/v1.0/documents/") assert response.status_code == 200 @@ -398,11 +403,11 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries factories.DocumentFactory.create_batch(2, users=[user]) url = "/api/v1.0/documents/" - with django_assert_num_queries(14): + with django_assert_num_queries(15): response = client.get(url) # nb_accesses should now be cached - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get(url) assert response.status_code == 200 @@ -415,7 +420,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries for document in special_documents: models.DocumentFavorite.objects.create(document=document, user=user) - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get(url) assert response.status_code == 200 diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 683267c1b..5d62375d2 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -13,6 +13,7 @@ import pytest from rest_framework.test import APIClient from core import choices, factories, models +from core.tests.utils.encryption import direct_user_subs pytestmark = pytest.mark.django_db @@ -28,6 +29,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "id": str(document.id), "abilities": { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": False, "ai_transform": False, @@ -77,6 +80,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "accesses_versions_per_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": "public", @@ -110,6 +115,8 @@ def test_api_documents_retrieve_anonymous_public_parent(): "id": str(document.id), "abilities": { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": False, "ai_transform": False, @@ -157,6 +164,8 @@ def test_api_documents_retrieve_anonymous_public_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "accesses_versions_per_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, @@ -222,6 +231,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "id": str(document.id), "abilities": { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": document.link_role == "editor", "ai_transform": document.link_role == "editor", @@ -270,6 +281,10 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "depth": 1, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": reach, @@ -311,6 +326,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "id": str(document.id), "abilities": { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": grand_parent.link_role == "editor", "ai_transform": grand_parent.link_role == "editor", @@ -357,6 +374,10 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "depth": 3, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, @@ -472,6 +493,10 @@ def test_api_documents_retrieve_authenticated_related_direct(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, @@ -513,6 +538,8 @@ def test_api_documents_retrieve_authenticated_related_parent(): "id": str(document.id), "abilities": { "accesses_manage": access.role in ["administrator", "owner"], + "encrypt": access.role in ["administrator", "owner"], + "remove_encryption": access.role in ["administrator", "owner"], "accesses_view": True, "ai_proxy": access.role not in ["reader", "commenter"], "ai_transform": access.role not in ["reader", "commenter"], @@ -559,6 +586,10 @@ def test_api_documents_retrieve_authenticated_related_parent(): "depth": 3, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": "restricted", @@ -716,6 +747,10 @@ def test_api_documents_retrieve_authenticated_related_team_members( "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": "restricted", @@ -783,6 +818,10 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": "restricted", @@ -850,6 +889,10 @@ def test_api_documents_retrieve_authenticated_related_team_owners( "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "is_favorite": False, "is_encrypted": document.is_encrypted, "link_reach": "restricted", @@ -905,10 +948,10 @@ def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_que document = factories.DocumentFactory(users=[user], link_traces=[user]) - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get(f"/api/v1.0/documents/{document.id!s}/") - with django_assert_num_queries(3): + with django_assert_num_queries(4): response = client.get(f"/api/v1.0/documents/{document.id!s}/") assert response.status_code == 200 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 4ccc1f00b..7b5db4e9a 100644 --- a/src/backend/core/tests/documents/test_api_documents_search.py +++ b/src/backend/core/tests/documents/test_api_documents_search.py @@ -15,6 +15,7 @@ from core import factories from core.enums import FeatureFlag, SearchType from core.models import LinkReachChoices from core.services.search_indexers import get_document_indexer +from core.tests.utils.encryption import direct_user_subs fake = Faker() pytestmark = pytest.mark.django_db @@ -127,13 +128,13 @@ def test_api_documents_search_fall_back_on_simple_search( client.force_login(user) q = "alpha" - with django_assert_num_queries(13): + with django_assert_num_queries(14): response = client.get("/api/v1.0/documents/search/", data={"q": q}) assert response.status_code == 200 # all `nb_access_*` should be in cache - with django_assert_num_queries(6): + with django_assert_num_queries(7): response = client.get("/api/v1.0/documents/search/", data={"q": q}) assert response.status_code == 200 @@ -154,6 +155,11 @@ def test_api_documents_search_fall_back_on_simple_search( "deleted_at": None, "depth": 2, "excerpt": child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "link_reach": child.link_reach, @@ -176,6 +182,11 @@ def test_api_documents_search_fall_back_on_simple_search( "deleted_at": None, "depth": 1, "excerpt": parent.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "link_reach": parent.link_reach, @@ -200,6 +211,11 @@ def test_api_documents_search_fall_back_on_simple_search( "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -271,6 +287,11 @@ def test_api_documents_search_simple_search_only_match_in_depth(settings): "deleted_at": None, "depth": 2, "excerpt": child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "link_reach": child.link_reach, @@ -293,6 +314,11 @@ def test_api_documents_search_simple_search_only_match_in_depth(settings): "deleted_at": None, "depth": 1, "excerpt": parent.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "link_reach": parent.link_reach, @@ -317,6 +343,11 @@ def test_api_documents_search_simple_search_only_match_in_depth(settings): "deleted_at": None, "depth": 2, "excerpt": subdocument.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(subdocument), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(subdocument.id), "is_favorite": False, "link_reach": subdocument.link_reach, @@ -341,6 +372,11 @@ def test_api_documents_search_simple_search_only_match_in_depth(settings): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -423,6 +459,11 @@ def test_api_documents_search_with_title_also_matching_link_traces(settings): "deleted_at": None, "depth": 1, "excerpt": document_link_trace.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document_link_trace), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document_link_trace.id), "is_favorite": False, "link_reach": document_link_trace.link_reach, @@ -449,6 +490,11 @@ def test_api_documents_search_with_title_also_matching_link_traces(settings): "deleted_at": None, "depth": 2, "excerpt": child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "link_reach": child.link_reach, @@ -471,6 +517,11 @@ def test_api_documents_search_with_title_also_matching_link_traces(settings): "deleted_at": None, "depth": 1, "excerpt": parent.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "link_reach": parent.link_reach, @@ -495,6 +546,11 @@ def test_api_documents_search_with_title_also_matching_link_traces(settings): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, 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 index a542e4658..5bdd253bc 100644 --- a/src/backend/core/tests/documents/test_api_documents_search_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_search_descendants.py @@ -13,6 +13,7 @@ from rest_framework.test import APIClient from core import factories from core.api.filters import remove_accents +from core.tests.utils.encryption import direct_user_subs pytestmark = pytest.mark.django_db @@ -55,6 +56,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -79,6 +85,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -103,6 +114,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -129,6 +145,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -153,6 +174,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -179,6 +205,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -203,6 +234,11 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -269,6 +305,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -293,6 +334,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -317,6 +363,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -343,6 +394,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 5, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -367,6 +423,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -393,6 +454,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -417,6 +483,11 @@ def test_api_documents_search_descendants_list_anonymous_public_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -498,6 +569,11 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -522,6 +598,11 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -548,6 +629,11 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(grand_child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -572,6 +658,11 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -598,6 +689,11 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -622,6 +718,11 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -689,6 +790,11 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -713,6 +819,11 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -739,6 +850,11 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic "deleted_at": None, "depth": 5, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(grand_child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -763,6 +879,11 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -789,6 +910,11 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -813,6 +939,11 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -900,6 +1031,11 @@ def test_api_documents_search_descendants_list_authenticated_related_direct(): "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -924,6 +1060,11 @@ def test_api_documents_search_descendants_list_authenticated_related_direct(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -950,6 +1091,11 @@ def test_api_documents_search_descendants_list_authenticated_related_direct(): "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(grand_child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -974,6 +1120,11 @@ def test_api_documents_search_descendants_list_authenticated_related_direct(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1000,6 +1151,11 @@ def test_api_documents_search_descendants_list_authenticated_related_direct(): "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -1024,6 +1180,11 @@ def test_api_documents_search_descendants_list_authenticated_related_direct(): "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1092,6 +1253,11 @@ def test_api_documents_search_descendants_list_authenticated_related_parent(): "deleted_at": None, "depth": 4, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -1116,6 +1282,11 @@ def test_api_documents_search_descendants_list_authenticated_related_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1142,6 +1313,11 @@ def test_api_documents_search_descendants_list_authenticated_related_parent(): "deleted_at": None, "depth": 5, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(grand_child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -1166,6 +1342,11 @@ def test_api_documents_search_descendants_list_authenticated_related_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1192,6 +1373,11 @@ def test_api_documents_search_descendants_list_authenticated_related_parent(): "deleted_at": None, "depth": 4, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -1216,6 +1402,11 @@ def test_api_documents_search_descendants_list_authenticated_related_parent(): "deleted_at": None, "depth": 3, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1331,6 +1522,11 @@ def test_api_documents_search_descendants_list_authenticated_related_team_member "deleted_at": None, "depth": 2, "excerpt": child1.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child1), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child1.id), "is_favorite": False, "link_reach": child1.link_reach, @@ -1355,6 +1551,11 @@ def test_api_documents_search_descendants_list_authenticated_related_team_member "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1381,6 +1582,11 @@ def test_api_documents_search_descendants_list_authenticated_related_team_member "deleted_at": None, "depth": 3, "excerpt": grand_child.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(grand_child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_child.id), "is_favorite": False, "link_reach": grand_child.link_reach, @@ -1405,6 +1611,11 @@ def test_api_documents_search_descendants_list_authenticated_related_team_member "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, @@ -1431,6 +1642,11 @@ def test_api_documents_search_descendants_list_authenticated_related_team_member "deleted_at": None, "depth": 2, "excerpt": child2.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(child2), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child2.id), "is_favorite": False, "link_reach": child2.link_reach, @@ -1455,6 +1671,11 @@ def test_api_documents_search_descendants_list_authenticated_related_team_member "deleted_at": None, "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "link_reach": document.link_reach, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index a6f5668eb..caab9dbd1 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -13,6 +13,7 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.test import APIClient from core import factories, models +from core.tests.utils.encryption import direct_user_subs fake = Faker() pytestmark = pytest.mark.django_db @@ -93,6 +94,7 @@ def test_api_documents_trashbin_format(): "formatted_content": False, "destroy": False, "duplicate": False, + "encrypt": False, "favorite": False, "invite_owner": False, "link_configuration": False, @@ -108,6 +110,7 @@ def test_api_documents_trashbin_format(): "media_check": False, "move": False, # Can't move a deleted document "partial_update": False, + "remove_encryption": False, "restore": True, "retrieve": True, "search": False, @@ -127,6 +130,11 @@ def test_api_documents_trashbin_format(): "creator": str(user.id), "depth": 1, "excerpt": other_document_to_delete.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(other_document_to_delete), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "deleted_at": other_document_to_delete.ancestors_deleted_at.isoformat().replace( "+00:00", "Z" ), @@ -161,6 +169,7 @@ def test_api_documents_trashbin_format(): "formatted_content": False, "destroy": False, "duplicate": False, + "encrypt": False, "favorite": False, "invite_owner": False, "link_configuration": False, @@ -176,6 +185,7 @@ def test_api_documents_trashbin_format(): "media_check": False, "move": False, # Can't move a deleted document "partial_update": False, + "remove_encryption": False, "restore": True, "retrieve": True, "search": False, @@ -193,6 +203,11 @@ def test_api_documents_trashbin_format(): "creator": str(document.creator.id), "depth": 1, "excerpt": document.excerpt, + "is_encrypted": False, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "deleted_at": document.ancestors_deleted_at.isoformat().replace( "+00:00", "Z" ), @@ -250,10 +265,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries): expected_ids = {str(document1.id), str(document2.id), str(document3.id)} - with django_assert_num_queries(11): + with django_assert_num_queries(12): response = client.get("/api/v1.0/documents/trashbin/") - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get("/api/v1.0/documents/trashbin/") assert response.status_code == 200 @@ -292,10 +307,10 @@ def test_api_documents_trashbin_authenticated_via_team( expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)} - with django_assert_num_queries(8): + with django_assert_num_queries(9): response = client.get("/api/v1.0/documents/trashbin/") - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get("/api/v1.0/documents/trashbin/") assert response.status_code == 200 diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index 05ca0fd58..ec78d4336 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -11,6 +11,7 @@ import pytest from rest_framework.test import APIClient from core import factories +from core.tests.utils.encryption import direct_user_subs pytestmark = pytest.mark.django_db @@ -52,6 +53,10 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "depth": 3, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -77,6 +82,10 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "depth": 2, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -102,6 +111,10 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "depth": 2, "deleted_at": None, "excerpt": sibling1.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(sibling1.id), "is_favorite": False, "is_encrypted": sibling1.is_encrypted, @@ -127,6 +140,10 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "depth": 2, "deleted_at": None, "excerpt": sibling2.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(sibling2.id), "is_favorite": False, "is_encrypted": sibling2.is_encrypted, @@ -148,6 +165,10 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "depth": 1, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -222,6 +243,10 @@ def test_api_documents_tree_list_anonymous_public_parent(): "depth": 5, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -247,6 +272,10 @@ def test_api_documents_tree_list_anonymous_public_parent(): "depth": 4, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -276,6 +305,10 @@ def test_api_documents_tree_list_anonymous_public_parent(): "depth": 4, "deleted_at": None, "excerpt": document_sibling.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document_sibling.id), "is_favorite": False, "is_encrypted": document_sibling.is_encrypted, @@ -299,6 +332,10 @@ def test_api_documents_tree_list_anonymous_public_parent(): "depth": 3, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -326,6 +363,10 @@ def test_api_documents_tree_list_anonymous_public_parent(): "depth": 3, "deleted_at": None, "excerpt": parent_sibling.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent_sibling.id), "is_favorite": False, "is_encrypted": parent_sibling.is_encrypted, @@ -349,6 +390,10 @@ def test_api_documents_tree_list_anonymous_public_parent(): "depth": 2, "deleted_at": None, "excerpt": grand_parent.excerpt, + "accesses_user_ids": None, + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_parent.id), "is_favorite": False, "is_encrypted": grand_parent.is_encrypted, @@ -399,10 +444,10 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) child = factories.DocumentFactory(link_reach="public", parent=document) - with django_assert_num_queries(13): + with django_assert_num_queries(14): client.get(f"/api/v1.0/documents/{document.id!s}/tree/") - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") assert response.status_code == 200 @@ -430,6 +475,10 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "depth": 3, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -453,6 +502,10 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "depth": 2, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -478,6 +531,10 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "depth": 2, "deleted_at": None, "excerpt": sibling.excerpt, + "accesses_user_ids": direct_user_subs(sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(sibling.id), "is_favorite": False, "is_encrypted": sibling.is_encrypted, @@ -499,6 +556,10 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "depth": 1, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -578,6 +639,10 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "depth": 5, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -603,6 +668,10 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "depth": 4, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -632,6 +701,10 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "depth": 4, "deleted_at": None, "excerpt": document_sibling.excerpt, + "accesses_user_ids": direct_user_subs(document_sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document_sibling.id), "is_favorite": False, "is_encrypted": document_sibling.is_encrypted, @@ -655,6 +728,10 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "depth": 3, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -682,6 +759,10 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "depth": 3, "deleted_at": None, "excerpt": parent_sibling.excerpt, + "accesses_user_ids": direct_user_subs(parent_sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent_sibling.id), "is_favorite": False, "is_encrypted": parent_sibling.is_encrypted, @@ -705,6 +786,10 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "depth": 2, "deleted_at": None, "excerpt": grand_parent.excerpt, + "accesses_user_ids": direct_user_subs(grand_parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_parent.id), "is_favorite": False, "is_encrypted": grand_parent.is_encrypted, @@ -788,6 +873,10 @@ def test_api_documents_tree_list_authenticated_related_direct(): "depth": 3, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -811,6 +900,10 @@ def test_api_documents_tree_list_authenticated_related_direct(): "depth": 2, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -836,6 +929,10 @@ def test_api_documents_tree_list_authenticated_related_direct(): "depth": 2, "deleted_at": None, "excerpt": sibling.excerpt, + "accesses_user_ids": direct_user_subs(sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(sibling.id), "is_favorite": False, "is_encrypted": sibling.is_encrypted, @@ -857,6 +954,10 @@ def test_api_documents_tree_list_authenticated_related_direct(): "depth": 1, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -940,6 +1041,10 @@ def test_api_documents_tree_list_authenticated_related_parent(): "depth": 5, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -965,6 +1070,10 @@ def test_api_documents_tree_list_authenticated_related_parent(): "depth": 4, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -994,6 +1103,10 @@ def test_api_documents_tree_list_authenticated_related_parent(): "depth": 4, "deleted_at": None, "excerpt": document_sibling.excerpt, + "accesses_user_ids": direct_user_subs(document_sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document_sibling.id), "is_favorite": False, "is_encrypted": document_sibling.is_encrypted, @@ -1017,6 +1130,10 @@ def test_api_documents_tree_list_authenticated_related_parent(): "depth": 3, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -1044,6 +1161,10 @@ def test_api_documents_tree_list_authenticated_related_parent(): "depth": 3, "deleted_at": None, "excerpt": parent_sibling.excerpt, + "accesses_user_ids": direct_user_subs(parent_sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent_sibling.id), "is_favorite": False, "is_encrypted": parent_sibling.is_encrypted, @@ -1067,6 +1188,10 @@ def test_api_documents_tree_list_authenticated_related_parent(): "depth": 2, "deleted_at": None, "excerpt": grand_parent.excerpt, + "accesses_user_ids": direct_user_subs(grand_parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(grand_parent.id), "is_favorite": False, "is_encrypted": grand_parent.is_encrypted, @@ -1158,6 +1283,10 @@ def test_api_documents_tree_list_authenticated_related_team_members( "depth": 3, "deleted_at": None, "excerpt": child.excerpt, + "accesses_user_ids": direct_user_subs(child), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(child.id), "is_favorite": False, "is_encrypted": child.is_encrypted, @@ -1181,6 +1310,10 @@ def test_api_documents_tree_list_authenticated_related_team_members( "depth": 2, "deleted_at": None, "excerpt": document.excerpt, + "accesses_user_ids": direct_user_subs(document), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(document.id), "is_favorite": False, "is_encrypted": document.is_encrypted, @@ -1206,6 +1339,10 @@ def test_api_documents_tree_list_authenticated_related_team_members( "depth": 2, "deleted_at": None, "excerpt": sibling.excerpt, + "accesses_user_ids": direct_user_subs(sibling), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(sibling.id), "is_favorite": False, "is_encrypted": sibling.is_encrypted, @@ -1227,6 +1364,10 @@ def test_api_documents_tree_list_authenticated_related_team_members( "depth": 1, "deleted_at": None, "excerpt": parent.excerpt, + "accesses_user_ids": direct_user_subs(parent), + "accesses_versions_per_user": None, + "encrypted_document_symmetric_key_for_user": None, + "is_pending_encryption_for_user": False, "id": str(parent.id), "is_favorite": False, "is_encrypted": parent.is_encrypted, @@ -1276,10 +1417,10 @@ def test_api_documents_tree_list_deleted_document_owner(django_assert_num_querie document.refresh_from_db() child.refresh_from_db() - with django_assert_num_queries(9): + with django_assert_num_queries(10): client.get(f"/api/v1.0/documents/{document.id!s}/tree/") - with django_assert_num_queries(5): + with django_assert_num_queries(6): response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") assert response.status_code == 200 diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 8c0b3fdd8..7908c965c 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -461,6 +461,7 @@ def test_api_users_retrieve_me_authenticated(): "language": user.language, "short_name": user.short_name, "is_first_connection": True, + "suite_user_id": user.sub, } @@ -491,6 +492,7 @@ def test_api_users_retrieve_me_authenticated_empty_name(): "language": user.language, "short_name": "test_foo", "is_first_connection": True, + "suite_user_id": user.sub, } diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index b8c3e93dd..9b22b4040 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -86,6 +86,7 @@ def test_models_document_access_get_abilities_anonymous(): abilities = access.get_abilities(AnonymousUser()) assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -100,6 +101,7 @@ def test_models_document_access_get_abilities_authenticated(): abilities = access.get_abilities(user) assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -120,6 +122,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed(): abilities = access.get_abilities(access.user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -141,6 +144,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_root( assert abilities == { "destroy": False, + "encryption_key": True, "retrieve": True, "update": False, "partial_update": False, @@ -163,6 +167,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child( assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -180,6 +185,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner(): abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -197,6 +203,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator(): abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -214,6 +221,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor(): abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -231,6 +239,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader(): abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -251,6 +260,7 @@ def test_models_document_access_get_abilities_for_administrator_of_owner(): abilities = access.get_abilities(user) assert abilities == { "destroy": False, + "encryption_key": True, "retrieve": True, "update": False, "partial_update": False, @@ -268,6 +278,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -285,6 +296,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor(): abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -302,6 +314,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader(): abilities = access.get_abilities(user) assert abilities == { "destroy": True, + "encryption_key": True, "retrieve": True, "update": True, "partial_update": True, @@ -322,6 +335,7 @@ def test_models_document_access_get_abilities_for_editor_of_owner(): abilities = access.get_abilities(user) assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -339,6 +353,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator(): abilities = access.get_abilities(user) assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -361,6 +376,7 @@ def test_models_document_access_get_abilities_for_editor_of_editor_user( assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -381,6 +397,7 @@ def test_models_document_access_get_abilities_for_reader_of_owner(): abilities = access.get_abilities(user) assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -398,6 +415,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator(): abilities = access.get_abilities(user) assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -420,6 +438,7 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user( assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, @@ -440,6 +459,7 @@ def test_models_document_access_get_abilities_preset_role(django_assert_num_quer assert abilities == { "destroy": False, + "encryption_key": False, "retrieve": False, "update": False, "partial_update": False, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index b6bd19768..f0247ff30 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -154,6 +154,8 @@ def test_models_documents_get_abilities_forbidden( user = factories.UserFactory() if is_authenticated else AnonymousUser() expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": False, "ai_transform": False, @@ -223,6 +225,8 @@ def test_models_documents_get_abilities_reader( user = factories.UserFactory() if is_authenticated else AnonymousUser() expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": False, "ai_transform": False, @@ -297,6 +301,8 @@ def test_models_documents_get_abilities_commenter( user = factories.UserFactory() if is_authenticated else AnonymousUser() expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": False, "ai_transform": False, @@ -368,6 +374,8 @@ def test_models_documents_get_abilities_editor( user = factories.UserFactory() if is_authenticated else AnonymousUser() expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": is_authenticated, "ai_transform": is_authenticated, @@ -428,6 +436,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): document = factories.DocumentFactory(users=[(user, "owner")]) expected_abilities = { "accesses_manage": True, + "encrypt": True, + "remove_encryption": True, "accesses_view": True, "ai_proxy": True, "ai_transform": True, @@ -474,6 +484,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): document.refresh_from_db() assert document.get_abilities(user) == { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": False, "ai_proxy": False, "ai_transform": False, @@ -524,6 +536,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) document = factories.DocumentFactory(users=[(user, "administrator")]) expected_abilities = { "accesses_manage": True, + "encrypt": True, + "remove_encryption": True, "accesses_view": True, "ai_proxy": True, "ai_transform": True, @@ -584,6 +598,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): document = factories.DocumentFactory(users=[(user, "editor")]) expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": True, "ai_proxy": True, "ai_transform": True, @@ -649,6 +665,8 @@ def test_models_documents_get_abilities_reader_user( expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": True, # If you get your editor rights from the link role and not your access role # You should not access AI if it's restricted to users with specific access @@ -719,6 +737,8 @@ def test_models_documents_get_abilities_commenter_user( expected_abilities = { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": True, # If you get your editor rights from the link role and not your access role # You should not access AI if it's restricted to users with specific access @@ -786,6 +806,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): assert abilities == { "accesses_manage": False, + "encrypt": False, + "remove_encryption": False, "accesses_view": True, "ai_proxy": False, "ai_transform": False, diff --git a/src/backend/core/tests/test_services_search_indexers.py b/src/backend/core/tests/test_services_search_indexers.py index 64a487eb4..4bd8458ea 100644 --- a/src/backend/core/tests/test_services_search_indexers.py +++ b/src/backend/core/tests/test_services_search_indexers.py @@ -241,7 +241,7 @@ def test_services_search_indexers_serialize_document_encrypted(): """Encrypted documents should have empty content to avoid indexing ciphertext.""" document = factories.DocumentFactory(is_encrypted=True) - indexer = SearchIndexer() + indexer = FindDocumentIndexer() result = indexer.serialize_document(document, {}) assert result["content"] == "" diff --git a/src/backend/core/tests/utils/encryption.py b/src/backend/core/tests/utils/encryption.py new file mode 100644 index 000000000..3b0e271b2 --- /dev/null +++ b/src/backend/core/tests/utils/encryption.py @@ -0,0 +1,16 @@ +"""Helpers to build the expected encryption fields of serialized documents.""" + +from core import models + + +def direct_user_subs(document): + """ + Sorted subs of the users with a direct access to the document, as serialized + in `accesses_user_ids` for authenticated users. + """ + return sorted( + str(sub) + for sub in models.DocumentAccess.objects.filter( + document=document, user__isnull=False + ).values_list("user__sub", flat=True) + ) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 79c7be6ac..bb76eaa73 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -1421,6 +1421,10 @@ class Test(Base): CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) + # The development environment stores the OIDC access token for the encryption + # service; tests start from the default and enable it where they need it. + OIDC_STORE_ACCESS_TOKEN = False + STORAGES = { "default": { "BACKEND": "storages.backends.s3.S3Storage", diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts index fd5dc35ee..796c998ba 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts @@ -18,6 +18,11 @@ export class EncryptedWebSocket extends WebSocket { super(address, protocols); const originalAddEventListener = this.addEventListener.bind(this); + const originalRemoveEventListener = this.removeEventListener.bind(this); + // The decrypting wrappers of the message listeners, so that detaching the + // socket (see the `onmessage` setter) can remove them. + const messageListeners: EventListener[] = []; + let detached = false; this.addEventListener = function ( type: K, @@ -50,6 +55,12 @@ export class EncryptedWebSocket extends WebSocket { this.keyVersion, ); + // Detached while this frame was being decrypted: drop it, as the + // provider no longer listens to this socket. + if (detached) { + return; + } + const decryptedData = new Uint8Array(decryptedBuffer); if (typeof listener === 'function') { @@ -66,17 +77,18 @@ export class EncryptedWebSocket extends WebSocket { } }; + messageListeners.push(wrappedListener); originalAddEventListener('message', wrappedListener, options); } else { originalAddEventListener(type, listener, options); } }; - // Block direct onmessage assignment + // Block direct onmessage assignment: a handler set that way would receive + // the ciphertext, bypassing the decrypting listeners above. let explicitlySetListener: // eslint-disable-next-line @typescript-eslint/no-explicit-any - ((this: WebSocket, handlerEvent: MessageEvent) => any) | null; - null; + ((this: WebSocket, handlerEvent: MessageEvent) => any) | null = null; Object.defineProperty(this, 'onmessage', { configurable: true, @@ -85,9 +97,24 @@ export class EncryptedWebSocket extends WebSocket { return explicitlySetListener; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors lib.dom WebSocket.onmessage signature (=> any) - set(_handler: ((handlerEvent: MessageEvent) => any) | null) { + set(handler: ((handlerEvent: MessageEvent) => any) | null) { explicitlySetListener = null; + // y-websocket detaches a socket it drops with `onmessage = null`, so that + // frames still buffered while it closes cannot touch the provider. The + // provider listens through addEventListener here (see the y-websocket + // patch), so detaching means removing those listeners. + if (handler === null) { + detached = true; + messageListeners + .splice(0) + .forEach((listener) => + originalRemoveEventListener('message', listener), + ); + + return; + } + throw new Error( '"onmessage" should not be set directly. Use addEventListener instead. Run "yarn run patch-package"!', ); @@ -100,10 +127,21 @@ export class EncryptedWebSocket extends WebSocket { } send(message: Uint8Array) { - // Encrypt directly with ArrayBuffer — no base64 conversion + if (this.readyState !== WebSocket.OPEN) { + return; + } + + // Encrypt directly with ArrayBuffer, no base64 conversion this.vaultClient .encryptWithKey(message.buffer, this.encryptedSymmetricKey) .then(({ encryptedData }) => { + // Encryption is asynchronous: the socket may have closed meanwhile (the + // editor publishes its presence removal while it unmounts). Nobody is + // left to receive it on this socket, so it is dropped. + if (this.readyState !== WebSocket.OPEN) { + return; + } + super.send(new Uint8Array(encryptedData)); }) .catch((error) => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx new file mode 100644 index 000000000..170ac8c30 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx @@ -0,0 +1,301 @@ +/** + * AccessibleImageBlock.tsx + * + * Custom BlockNote block for accessible images with encryption support. + * + * Accessibility (RGAA 1.9.1): + * - Images with captions are wrapped in
and
elements. + * - The element has an appropriate alt attribute based on the caption. + * - Images without captions have alt="" and are marked as decorative with aria-hidden="true". + * + * Encryption: + * - Images < 2MB are auto-decrypted inline. + * - Images >= 2MB show a "click to decrypt" placeholder. + * + * https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts + */ + +import { + BlockNoDefaults, + BlockNoteEditor, + InlineContentSchema, + StyleSchema, + createImageBlockConfig, + imageParse, +} from '@blocknote/core'; +import { + ResizableFileBlockWrapper, + createReactBlockSpec, +} from '@blocknote/react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Icon, Loading } from '@/components'; + +import { ANALYZE_URL } from '../../conf'; +import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder'; +import { useEncryption } from '../EncryptionProvider'; + +type ImageBlockConfig = ReturnType; + +const AUTOMATIC_DECRYPTION_MAX_SIZE = 2 * 1024 * 1024; // 2 MB + +interface AccessibleImageProps { + src: string; + caption: string; +} + +const AccessibleImage = ({ src, caption }: AccessibleImageProps) => { + const { t } = useTranslation(); + + if (caption) { + return ( +
+ {caption} +
{caption}
+
+ ); + } + + return ( + + ); +}; + +interface ImageBlockComponentProps { + block: BlockNoDefaults< + Record<'image', ImageBlockConfig>, + InlineContentSchema, + StyleSchema + >; + contentRef: (node: HTMLElement | null) => void; + editor: BlockNoteEditor< + Record<'image', ImageBlockConfig>, + InlineContentSchema, + StyleSchema + >; +} + +const ImageBlockComponent = ({ + editor, + block, + ...rest +}: ImageBlockComponentProps) => { + const { t } = useTranslation(); + const { isEncrypted, decryptFileUrl } = useEncryption(); + + const url = block.props.url; + const caption = block.props.caption || ''; + const isAnalyzing = !!url && url.includes(ANALYZE_URL); + + // Encrypted state + const [resolvedUrl, setResolvedUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [showClickPlaceholder, setShowClickPlaceholder] = useState(false); + + // Auto-decrypt small files, show placeholder for large ones + useEffect(() => { + if (!isEncrypted || !url || isAnalyzing) { + return; + } + + let cancelled = false; + setIsLoading(true); + + fetch(url, { method: 'HEAD', credentials: 'include' }) + .then(async (headResponse) => { + if (cancelled) { + return; + } + + const contentLength = Number( + headResponse.headers.get('content-length'), + ); + + // Larger images show a "click to decrypt" placeholder instead to save decryption processing + // (needed since photos taken from a smartphone can easily be over 15MB) + if (contentLength < AUTOMATIC_DECRYPTION_MAX_SIZE) { + try { + const blobUrl = await decryptFileUrl(url); + + if (!cancelled) { + setResolvedUrl(blobUrl); + } + } catch { + if (!cancelled) { + setShowClickPlaceholder(true); + } + } + } else { + if (!cancelled) { + setShowClickPlaceholder(true); + } + } + }) + .catch(() => { + if (!cancelled) { + setShowClickPlaceholder(true); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isEncrypted, url, isAnalyzing, decryptFileUrl]); + + const handleDecrypt = useCallback(async () => { + if (!url) { + return; + } + + setIsLoading(true); + setHasError(false); + try { + const blobUrl = await decryptFileUrl(url); + setResolvedUrl(blobUrl); + setShowClickPlaceholder(false); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + } + }, [url, decryptFileUrl]); + + // Remove the duplicate

added by ResizableFileBlockWrapper + // when we render our own

inside a
. + const wrapperRef = useRef(null); + useLayoutEffect(() => { + if (!wrapperRef.current || !caption) { + return; + } + + const wrapper = wrapperRef.current.closest( + '.bn-file-block-content-wrapper', + ); + if (!wrapper) { + return; + } + + const pCaption = wrapper.querySelector(':scope > p.bn-file-caption'); + if (pCaption) { + pCaption.remove(); + } + }, [caption]); + + const effectiveUrl = isEncrypted ? resolvedUrl : url; + const showMedia = !!effectiveUrl && !isAnalyzing; + const showEncryptedPlaceholder = + isEncrypted && (showClickPlaceholder || hasError) && !resolvedUrl; + + // ResizableFileBlockWrapper's props type is internal to @blocknote/react and not exported. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapperProps = { editor, block, ...rest } as any; + + return ( + + } + > + {isEncrypted && isLoading && !resolvedUrl && !showClickPlaceholder && ( + + )} + {showEncryptedPlaceholder && ( + void handleDecrypt()} + /> + )} + {showMedia && ( + + + + )} + + ); +}; + +const ImageToExternalHTML = ({ + block, +}: { + block: BlockNoDefaults< + Record<'image', ImageBlockConfig>, + InlineContentSchema, + StyleSchema + >; +}) => { + if (!block.props.url) { + return

Add image

; + } + + const img = ( + {block.props.caption + ); + + if (block.props.caption) { + return ( +
+ {img} +
{block.props.caption}
+
+ ); + } + + return img; +}; + +export const AccessibleImageBlock = createReactBlockSpec( + createImageBlockConfig, + (config) => ({ + meta: { + fileBlockAccept: ['image/*'], + }, + render: (props) => ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + + ), + parse: imageParse(config), + toExternalHTML: (props) => , + runsBefore: ['file'], + }), +); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx index 0f468e4b6..fbff4219b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -12,6 +12,12 @@ vi.mock('next/router', () => ({ useRouter: vi.fn(), })); +const encryptWithKey = vi.fn(); + +vi.mock('@/docs/doc-collaboration/vault', () => ({ + useVaultClient: () => ({ client: { encryptWithKey } }), +})); + vi.mock('@/docs/doc-versioning', () => ({ KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions', })); @@ -238,6 +244,45 @@ describe('useSaveDoc', () => { expect(event.defaultPrevented).toBe(true); }); + it('should hold the unload back while an encrypted save is pending, then save it encrypted', async () => { + const yDoc = new Y.Doc(); + const docId = self.crypto.randomUUID(); + encryptWithKey.mockResolvedValue({ + encryptedData: new Uint8Array([1, 2, 3]).buffer, + }); + + fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { + body: JSON.stringify({ id: docId, content: 'AQID' }), + }); + + renderHook( + () => + useSaveDoc(docId, yDoc, true, { + encryptedSymmetricKey: new ArrayBuffer(8), + keyVersion: 1, + }), + { wrapper: AppWrapper }, + ); + + act(() => { + yDoc.getMap('test').set('key', 'value'); + }); + + const event = dispatchBeforeUnload(); + + // The vault encrypts asynchronously: the prompt keeps the page alive + expect(event.defaultPrevented).toBe(true); + + await waitFor(() => { + expect(fetchMock.callHistory.lastCall()?.url).toBe( + `http://test.jest/api/v1.0/documents/${docId}/content/`, + ); + }); + expect( + JSON.parse(fetchMock.callHistory.lastCall()?.options.body as string), + ).toMatchObject({ content: 'AQID', contentEncrypted: true }); + }); + it('should cleanup event listeners on unmount', () => { const yDoc = new Y.Doc(); const docId = 'test-doc-id'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index a48c07bea..2d82cc271 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -131,12 +131,21 @@ export const useSaveDoc = ( documentEncryptionSettings.encryptedSymmetricKey, ) .then(({ encryptedData }) => { + const content = toBase64(new Uint8Array(encryptedData)); updateDocContent({ id: docId, - content: toBase64(new Uint8Array(encryptedData)), + content, contentEncrypted: true, websocket, - keepalive: isUnloading, + // A keepalive request over the browser's size cap is refused + // outright: a larger one goes as a regular request instead. + keepalive: + isUnloading && + canKeepaliveContent({ + content, + contentEncrypted: true, + websocket, + }), }); }) .catch((err) => { @@ -181,8 +190,23 @@ export const useSaveDoc = ( useEffect(() => { const onSave = (e?: Event) => { const isUnloading = typeof e !== 'undefined' && e.type === 'beforeunload'; + // Read before saving: starting the save below flips the in-flight flag. + const hasUnsavedEncryptedChanges = + isEncrypted && (isLocalChange || isSavingRef.current); const { isSaving, isKeptAlive } = saveDoc({ isUnloading }); + /** + * An encrypted save goes through the vault first, which is asynchronous, + * so the page would be gone before the request is sent. Asking the user + * to confirm keeps the page alive meanwhile: the save completes behind + * the prompt, and leaving right away is still possible. + */ + if (isUnloading && hasUnsavedEncryptedChanges && e.preventDefault) { + e.preventDefault(); + + return; + } + /** * Firefox does not trigger the request every time the user leaves the page. * Plus the request is not intercepted by the service worker. @@ -218,5 +242,5 @@ export const useSaveDoc = ( removeEventListener('beforeunload', onSave); router.events.off('routeChangeStart', onSave); }; - }, [router.events, saveDoc]); + }, [router.events, saveDoc, isEncrypted, isLocalChange]); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx index e9c6c79e1..1705d3dc5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/PendingEncryptionSection.tsx @@ -212,18 +212,6 @@ export const PendingEncryptionSection = ({ {access.user.email} )} - {canAccept && ( - - - - {t('Encryption enabled')} - - - )} {error && ( {error} diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 8711fbfb5..5808da4b5 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -1406,7 +1406,6 @@ "Encrypted": "Chiffré", "Encrypted document": "Document chiffré", "Encryption": "Chiffrement", - "Encryption enabled": "Chiffrement activé", "Encryption in progress": "Chiffrement en cours", "Encryption service unavailable": "Service de chiffrement indisponible", "Encryption settings": "Paramètres de chiffrement", diff --git a/src/frontend/servers/y-provider/__tests__/collaborationWSHandler.test.ts b/src/frontend/servers/y-provider/__tests__/collaborationWSHandler.test.ts index 0ef9137cc..2fa7de14b 100644 --- a/src/frontend/servers/y-provider/__tests__/collaborationWSHandler.test.ts +++ b/src/frontend/servers/y-provider/__tests__/collaborationWSHandler.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'; import { Request } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { WebSocket } from 'ws'; vi.mock('@/servers/hocuspocusServer', () => ({ @@ -58,6 +58,10 @@ const mockDocument = (isEncrypted: boolean) => { }; describe('collaborationWSHandler', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + test('forwards a plain document connection to hocuspocus', async () => { mockDocument(false); const room = uuidv4(); @@ -117,4 +121,71 @@ describe('collaborationWSHandler', () => { consoleErrorMock.mockRestore(); }); + + test.each([ + ['not a uuid', 'not-a-uuid'], + ['not a uuid v4', 'c8c1b4b0-6b1f-11ee-8c99-0242ac120002'], + ])('refuses a room that is %s', async (_label, room) => { + const { ws, closeMock } = createFakeWs(); + + await collaborationWSHandler(ws, createRequest(room)); + + expect(CollaborationBackend.fetchDocument).not.toHaveBeenCalled(); + expect(closeMock).toHaveBeenCalledWith(1008, 'unauthorized'); + }); + + test('refuses a user the backend denies the document to', async () => { + vi.mocked(CollaborationBackend.fetchDocument).mockRejectedValue( + new Error('403'), + ); + const consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const { ws, closeMock } = createFakeWs(); + + await collaborationWSHandler(ws, createRequest(uuidv4())); + + expect(closeMock).toHaveBeenCalledWith(1011, 'internal error'); + expect(handleConnectionMock).not.toHaveBeenCalled(); + consoleErrorMock.mockRestore(); + }); + + test('refuses a user without the retrieve ability', async () => { + vi.mocked(CollaborationBackend.fetchDocument).mockResolvedValue({ + is_encrypted: false, + abilities: { retrieve: false, update: false }, + } as Awaited>); + const consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const { ws, closeMock } = createFakeWs(); + + await collaborationWSHandler(ws, createRequest(uuidv4())); + + expect(closeMock).toHaveBeenCalledWith(1008, 'unauthorized'); + expect(handleConnectionMock).not.toHaveBeenCalled(); + consoleErrorMock.mockRestore(); + }); + + test('opens a plain document read-only without the update ability, and without a user id when there is no user', async () => { + vi.mocked(CollaborationBackend.fetchDocument).mockResolvedValue({ + is_encrypted: false, + abilities: { retrieve: true, update: false }, + } as Awaited>); + vi.mocked(CollaborationBackend.fetchCurrentUser).mockRejectedValue( + new Error('401'), + ); + handleConnectionMock.mockClear(); + const room = uuidv4(); + const { ws } = createFakeWs(); + const req = createRequest(room); + + await collaborationWSHandler(ws, req); + + expect(handleConnectionMock).toHaveBeenCalledWith(ws, req, { + roomId: room, + readOnly: true, + sessionKey: 'abc', + }); + }); }); diff --git a/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts index ce0b6da17..b160db398 100644 --- a/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts +++ b/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts @@ -4,7 +4,7 @@ import { HocuspocusProvider, HocuspocusProviderWebsocket, } from '@hocuspocus/provider'; -import { v1 as uuidv1, v4 as uuidv4 } from 'uuid'; +import { v4 as uuidv4 } from 'uuid'; import { afterAll, afterEach, @@ -37,7 +37,6 @@ vi.mock('../src/api/collaborationBackend', () => ({ console.error = vi.fn(); console.log = vi.fn(); -import * as CollaborationBackend from '@/api/collaborationBackend'; import { COLLABORATION_SERVER_ORIGIN as origin, PORT as port } from '@/env'; import { promiseDone } from '@/helpers'; import { routes } from '@/routes'; @@ -177,259 +176,4 @@ describe('Server Tests', () => { return promise; }); - - test('WebSocket connection not allowed if room is not a valid uuid v4', () => { - const { promise, done } = promiseDone(); - const room = uuidv1(); - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=${room}`, - WebSocketPolyfill: WebSocket, - maxAttempts: 1, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: room, - onAuthenticationFailed: (data) => { - expect(console.log).toHaveBeenLastCalledWith( - expect.any(String), - ' --- ', - 'Room name is not a valid uuid:', - room, - ); - - wsHocus.stopConnectionAttempt(); - expect(data.reason).toBe('permission-denied'); - wsHocus.webSocket?.close(); - wsHocus.disconnect(); - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - provider.attach(); - - return promise; - }); - - test('WebSocket connection not allowed if room is not a valid uuid', () => { - const { promise, done } = promiseDone(); - const room = 'not-a-valid-uuid'; - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=${room}`, - WebSocketPolyfill: WebSocket, - maxAttempts: 1, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: room, - onAuthenticationFailed: (data) => { - expect(console.log).toHaveBeenLastCalledWith( - expect.any(String), - ' --- ', - 'Room name is not a valid uuid:', - room, - ); - - wsHocus.stopConnectionAttempt(); - expect(data.reason).toBe('permission-denied'); - wsHocus.webSocket?.close(); - wsHocus.disconnect(); - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - provider.attach(); - - return promise; - }); - - test('WebSocket connection fails if user can not access document', () => { - const { promise, done } = promiseDone(); - - const room = uuidv4(); - - const fetchDocumentMock = vi - .spyOn(CollaborationBackend, 'fetchDocument') - .mockRejectedValue(new Error('some error')); - - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=${room}`, - WebSocketPolyfill: WebSocket, - maxAttempts: 1, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: room, - onAuthenticationFailed: (data) => { - expect(console.error).toHaveBeenLastCalledWith( - '[onConnect]', - 'Backend error: Unauthorized', - ); - - wsHocus.stopConnectionAttempt(); - expect(data.reason).toBe('permission-denied'); - expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith( - { name: room }, - expect.any(Object), - ); - wsHocus.webSocket?.close(); - wsHocus.disconnect(); - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - provider.attach(); - - return promise; - }); - - test('WebSocket connection fails if user do not have correct retrieve ability', () => { - const { promise, done } = promiseDone(); - - const room = uuidv4(); - - const fetchDocumentMock = vi - .spyOn(CollaborationBackend, 'fetchDocument') - .mockResolvedValue({ abilities: { retrieve: false } } as any); - - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=${room}`, - WebSocketPolyfill: WebSocket, - maxAttempts: 1, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: room, - onAuthenticationFailed: (data) => { - expect(console.log).toHaveBeenLastCalledWith( - expect.any(String), - ' --- ', - 'onConnect: Unauthorized to retrieve this document', - room, - ); - - wsHocus.stopConnectionAttempt(); - expect(data.reason).toBe('permission-denied'); - expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith( - { name: room }, - expect.any(Object), - ); - wsHocus.webSocket?.close(); - wsHocus.disconnect(); - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - provider.attach(); - - return promise; - }); - - [true, false].forEach((canEdit) => { - test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => { - const { promise, done } = promiseDone(); - - const fetchDocumentMock = vi - .spyOn(CollaborationBackend, 'fetchDocument') - .mockResolvedValue({ - abilities: { retrieve: true, update: canEdit }, - } as any); - - const room = uuidv4(); - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=${room}`, - WebSocketPolyfill: WebSocket, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: room, - onConnect: () => { - void hocuspocusServer.hocuspocus - .openDirectConnection(room) - .then((connection) => { - connection.document?.getConnections().forEach((connection) => { - expect(connection.readOnly).toBe(!canEdit); - }); - - void connection.disconnect(); - - provider.destroy(); - wsHocus.destroy(); - - expect(fetchDocumentMock).toHaveBeenCalledWith( - { name: room }, - expect.any(Object), - ); - - done(); - }); - }, - }); - - provider.attach(); - - return promise; - }); - }); - - test('Add request header x-user-id if found', () => { - const { promise, done } = promiseDone(); - - const fetchDocumentMock = vi - .spyOn(CollaborationBackend, 'fetchDocument') - .mockResolvedValue({ - abilities: { retrieve: true, update: true }, - } as any); - - const fetchCurrentUserMock = vi - .spyOn(CollaborationBackend, 'fetchCurrentUser') - .mockResolvedValue({ id: 'test-user-id' } as any); - - const room = uuidv4(); - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=${room}`, - WebSocketPolyfill: WebSocket, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: room, - onConnect: () => { - const document = hocuspocusServer.hocuspocus.documents.get(room); - if (document) { - document.getConnections().forEach((connection) => { - expect(connection.context.userId).toBe('test-user-id'); - }); - } - - provider.destroy(); - wsHocus.destroy(); - - expect(fetchDocumentMock).toHaveBeenCalledWith( - { name: room }, - expect.any(Object), - ); - - expect(fetchCurrentUserMock).toHaveBeenCalled(); - - done(); - }, - }); - - provider.attach(); - - return promise; - }); });