mirror of
https://github.com/suitenumerique/docs.git
synced 2026-10-01 14:05:12 +02:00
wip
This commit is contained in:
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
+43
-5
@@ -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) => {
|
||||
|
||||
+301
@@ -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'],
|
||||
}),
|
||||
);
|
||||
+45
@@ -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]);
|
||||
};
|
||||
|
||||
-12
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user