This commit is contained in:
Thomas Ramé
2026-09-28 18:33:19 +02:00
parent e6ad09c3aa
commit 13276c4b94
30 changed files with 1562 additions and 371 deletions
+71 -30
View File
@@ -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"]
+6 -2
View File
@@ -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"
+54 -28
View File
@@ -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
"""
@@ -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,
}
@@ -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,
@@ -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,
@@ -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,
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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
@@ -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,
@@ -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,
@@ -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
@@ -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
+2
View File
@@ -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,
}
@@ -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,
@@ -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,
@@ -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"] == ""
@@ -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)
)
+4
View File
@@ -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",
@@ -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 <K extends keyof WebSocketEventMap>(
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<ArrayBuffer>) {
// 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) => {
@@ -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 <figure> and <figcaption> elements.
* - The <img> 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<typeof createImageBlockConfig>;
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 (
<figure
style={{ margin: 0 }}
role="img"
aria-label={t('Image: {{title}}', { title: caption })}
>
<img
className="bn-visual-media"
src={src}
alt={caption}
tabIndex={0}
contentEditable={false}
draggable={false}
/>
<figcaption className="bn-file-caption">{caption}</figcaption>
</figure>
);
}
return (
<img
className="bn-visual-media"
src={src}
alt=""
role="presentation"
aria-hidden="true"
tabIndex={-1}
contentEditable={false}
draggable={false}
/>
);
};
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<string | null>(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 <p class="bn-file-caption"> added by ResizableFileBlockWrapper
// when we render our own <figcaption> inside a <figure>.
const wrapperRef = useRef<HTMLElement>(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 (
<ResizableFileBlockWrapper
{...wrapperProps}
buttonIcon={
<Icon iconName="image" $size="24px" $css="line-height: normal;" />
}
>
{isEncrypted && isLoading && !resolvedUrl && !showClickPlaceholder && (
<Loading />
)}
{showEncryptedPlaceholder && (
<EncryptedMediaPlaceholder
label={t('Click to decrypt and view image')}
errorLabel={t('Failed to decrypt image.')}
isLoading={isLoading}
hasError={hasError}
onDecrypt={() => void handleDecrypt()}
/>
)}
{showMedia && (
<span ref={wrapperRef}>
<AccessibleImage src={effectiveUrl} caption={caption} />
</span>
)}
</ResizableFileBlockWrapper>
);
};
const ImageToExternalHTML = ({
block,
}: {
block: BlockNoDefaults<
Record<'image', ImageBlockConfig>,
InlineContentSchema,
StyleSchema
>;
}) => {
if (!block.props.url) {
return <p>Add image</p>;
}
const img = (
<img
src={block.props.url}
alt={block.props.caption || ''}
width={block.props.previewWidth}
/>
);
if (block.props.caption) {
return (
<figure role="img" aria-label={block.props.caption}>
{img}
<figcaption>{block.props.caption}</figcaption>
</figure>
);
}
return img;
};
export const AccessibleImageBlock = createReactBlockSpec(
createImageBlockConfig,
(config) => ({
meta: {
fileBlockAccept: ['image/*'],
},
render: (props) => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<ImageBlockComponent {...(props as any)} />
),
parse: imageParse(config),
toExternalHTML: (props) => <ImageToExternalHTML {...props} />,
runsBefore: ['file'],
}),
);
@@ -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';
@@ -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]);
};
@@ -212,18 +212,6 @@ export const PendingEncryptionSection = ({
{access.user.email}
</Text>
)}
{canAccept && (
<Box $direction="row" $align="center" $gap="4xs">
<Icon
iconName="verified_user"
$size="14px"
$theme="success"
/>
<Text $size="xs" $weight="500" $theme="success">
{t('Encryption enabled')}
</Text>
</Box>
)}
{error && (
<Text $size="xs" $theme="error">
{error}
@@ -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",
@@ -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<ReturnType<typeof CollaborationBackend.fetchDocument>>);
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<ReturnType<typeof CollaborationBackend.fetchDocument>>);
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',
});
});
});
@@ -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;
});
});