🔥(backend) remove CollaborationService and can-edit endpoint

The CollaborationService was doing nothing since we started the
migration to yhub, all the code using it is now removed. Also the
`can-edit` endpoint and all the safeguard mechanism relying on the
presence of other users connected to the websocket will not be used
anymore, it will be possible to replace all of this with yhub, so all
this code is also removed.
This commit is contained in:
Manuel Raynaud
2026-09-02 09:36:34 +02:00
parent e909c4905e
commit 03795436f4
26 changed files with 95 additions and 1406 deletions
+2
View File
@@ -85,6 +85,8 @@ and this project adheres to
the dev stack gains dedicated valkey and postgres services for yhub, and
the kick (reset-connections) and get-connections APIs have no yhub
equivalent yet — they are deferred with TODO(yhub) stubs
- 🔥(backend) remove the unused `CollaborationService`
- 💥(backend) remove the `documents/{id}/can-edit/` endpoint
- 💥(y-provider) the published `lasuite/impress-y-provider` image becomes
converter-only and no longer serves `/collaboration/ws/`; deployments using
the existing helm values lose collaboration until the helm chart routes
+2 -25
View File
@@ -1,36 +1,13 @@
# Collaboration
By default with Docs, collaboration is enabled. To allow the collaboration between users, a connection to a websocket server is made (the y-provider service), you only have to configure the Django backend URL in your y-provider service:
By default with Docs, collaboration is enabled. To allow the collaboration between users, a connection to a websocket server is made (the y-provider service), you only have to configure the Django backend URL and the allowed origin in your y-provider service:
```yaml
COLLABORATION_BACKEND_BASE_URL: https://{yourdocsdomain.tld}
COLLABORATION_SERVER_ORIGIN: https://{yourdocsdomain.tld}
```
An advanced configuration can be used in some cases when your users are not allowed to use websocket on their network.
## What happens when connection to the websocket is not allowed?
When multiple users access a Docs and the connection to the websocket is not allowed, then they will be in a situation where they can lose data.
They will lose data because they will erase each other modifications. You can also have a scenario with a mix of users connected to the websocket and some other not.
## Safeguard configuration
We have imagined a safeguard scenario, not enabled by default.
The idea is to give the priority to users connected to the websocket. While there is at least one user connected to the websocket, all other users not connected to the websocket can access the Docs in **read-only** mode.
To enable this safeguard, the Django application will have to fetch the `y-provider` service to retrieve some information in it.
In the Django configuration, you have to set these environment variables:
```yaml
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: True
COLLABORATION_API_URL: https://{yourdocsdomain.tld}/collaboration/api/
COLLABORATION_SERVER_SECRET: A-shared-secret-with-y-provider-service
```
In the y-provider service, you have to set these environment variables:
```yaml
COLLABORATION_SERVER_SECRET: A-shared-secret-with-y-provider-service
COLLABORATION_SERVER_ORIGIN: https://{yourdocsdomain.tld}
```
-4
View File
@@ -32,10 +32,7 @@ These are the environment variables you can set for the `impress-backend` contai
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_INACTIVITY_TIMEOUT | Timeout (in seconds) after which the user is considered inactive when there is no activity. The WebSocket is closed after this inactivity period. `None` means disabled. | None |
| COLLABORATION_WS_NOT_CONNECTED_READ_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
@@ -98,7 +95,6 @@ These are the environment variables you can set for the `impress-backend` contai
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| MEDIA_AUTH_ORIGINAL_URL_HEADER | Parameter containing the original request URL, as seen at the media auth endpoint, in CGI/WSGI form (HTTP_HEADER_NAME_ALL_CAPS_WITH_UNDERSCORES) | HTTP_X_ORIGINAL_URL |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
@@ -15,7 +15,6 @@ image:
backend:
replicas: 1
envVars:
COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: docs.127.0.0.1.nip.io
-1
View File
@@ -81,7 +81,6 @@ USER_RECONCILIATION_FORM_URL=http://localhost:3000
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY=true
COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds
-1
View File
@@ -2,6 +2,5 @@ Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
Y_PROVIDER_API_KEY=<generate a random key>
COLLABORATION_SERVER_SECRET=<generate a random key>
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
COLLABORATION_API_URL=https://${DOCS_HOST}/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=https://${DOCS_HOST}
COLLABORATION_LOGGING=true
-3
View File
@@ -180,7 +180,6 @@ class DocumentLightSerializer(serializers.ModelSerializer):
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
websocket = serializers.BooleanField(required=False, write_only=True)
file = serializers.FileField(
required=False, write_only=True, allow_null=True, max_length=255
)
@@ -210,7 +209,6 @@ class DocumentSerializer(ListDocumentSerializer):
"title",
"updated_at",
"user_role",
"websocket",
]
read_only_fields = [
"id",
@@ -312,7 +310,6 @@ class DocumentContentSerializer(serializers.Serializer):
"""Serializer for updating only the raw content of a document stored in S3."""
content = serializers.CharField(required=True)
websocket = serializers.BooleanField(required=False)
def validate_content(self, value):
"""Validate the content field."""
+1 -108
View File
@@ -53,7 +53,6 @@ from core.api.filters import remove_accents
from core.services import mime_types
from core.services.ai_services.blocknote import AIService
from core.services.ai_services.legacy import get_legacy_ai_service
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
ConversionError,
Converter,
@@ -72,7 +71,6 @@ from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.access import reset_service_connections_in_cascade
from core.tasks.mail import send_ask_for_access_mail
from core.utils.analytics import PosthogEventName, posthog_capture
from core.utils.dicts import lowercase_keys
@@ -763,81 +761,6 @@ class DocumentViewSet(
PosthogEventName.DOC_DELETED, self.request.user, {}, document=instance
)
def _can_user_edit_document(self, document_id, set_cache=False):
"""Check if the user can edit the document."""
try:
count, exists = CollaborationService().get_document_connection_info(
document_id,
self.request.session.session_key,
)
except requests.HTTPError as e:
logger.exception("Failed to call collaboration server: %s", e)
count = 0
exists = False
if count == 0:
# Nobody is connected to the websocket server
logger.debug("update without connection found in the websocket server")
cache_key = f"docs:no-websocket:{document_id}"
current_editor = cache.get(cache_key)
if not current_editor:
if set_cache:
cache.set(
cache_key,
self.request.session.session_key,
settings.NO_WEBSOCKET_CACHE_TIMEOUT,
)
return True
if current_editor != self.request.session.session_key:
return False
if set_cache:
cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT)
return True
if exists:
# Current user is connected to the websocket server
logger.debug("session key found in the websocket server")
return True
logger.debug(
"Users connected to the websocket but current editor not connected to it. Can not edit."
)
return False
def perform_update(self, serializer):
"""Check rules about collaboration."""
if (
not serializer.validated_data.get("websocket", False)
and settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY
and not self._can_user_edit_document(serializer.instance.id, set_cache=True)
):
raise drf.exceptions.PermissionDenied(
"You are not allowed to edit this document."
)
return super().perform_update(serializer)
@drf.decorators.action(
detail=True,
methods=["get"],
url_path="can-edit",
)
def can_edit(self, request, *args, **kwargs):
"""Check if the current user can edit the document."""
document = self.get_object()
can_edit = (
True
if not settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY
else self._can_user_edit_document(document.id)
)
return drf.response.Response({"can_edit": can_edit})
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -1831,9 +1754,6 @@ class DocumentViewSet(
serializer.save()
# Notify collaboration server about the link updated
reset_service_connections_in_cascade.delay(str(document.id))
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
@@ -2081,15 +2001,6 @@ class DocumentViewSet(
serializer = serializers.DocumentContentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if (
not serializer.validated_data.get("websocket", False)
and settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY
and not self._can_user_edit_document(document.id, set_cache=True)
):
raise drf.exceptions.PermissionDenied(
"You are not allowed to edit this document."
)
content = serializer.validated_data["content"]
try:
extracted_attachments = set(extract_attachments(content))
@@ -2856,26 +2767,12 @@ class DocumentAccessViewSet(
or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
if access.user:
access_user_id = str(access.user.id)
# Notify collaboration server about the access change
reset_service_connections_in_cascade.delay(
str(access.document.id), access_user_id
)
def perform_destroy(self, instance):
"""Delete an access to the document and notify the collaboration server."""
"""Delete an access to the document."""
# Snapshot the identifiers before deletion as Django resets the primary key
# on the instance once it is deleted.
access_id = str(instance.id)
document_id = str(instance.document_id)
user_id = str(instance.user.id)
instance.delete()
@@ -2885,9 +2782,6 @@ class DocumentAccessViewSet(
{"access_id": access_id, "document_id": document_id},
)
# Notify collaboration server about the access removed
reset_service_connections_in_cascade.delay(document_id, user_id)
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -3105,7 +2999,6 @@ class ConfigView(drf.views.APIView):
"AI_FEATURE_LEGACY_ENABLED",
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READ_ONLY",
"COLLABORATION_WS_INACTIVITY_TIMEOUT",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
"CONVERSION_FILE_MAX_SIZE",
-1
View File
@@ -1392,7 +1392,6 @@ class Document(MP_Node, BaseModel):
"ai_translate": ai_access,
"attachment_upload": can_update,
"media_check": can_get,
"can_edit": can_update,
"children_list": can_get,
"children_create": can_create_children,
"collaboration_auth": can_get,
@@ -1,40 +0,0 @@
"""Collaboration services."""
from logging import getLogger
logger = getLogger(__name__)
class CollaborationService:
"""Service class for Collaboration related operations."""
def reset_connections(self, document_id, user_id=None):
"""
Reset the connections of a document and all its descendants in the
collaboration server.
TODO(yhub): yhub exposes no kick API, so this is a no-op. The regression
is stronger than losing the hocuspocus disconnect: a revoked user keeps
their already-authorized websocket until it closes on its own, and the
edits they push in the meantime are durably persisted and re-served by
yhub (hocuspocus lost them with the room). Until yhub grows a kick API,
the manual remediation is yhub's rollback endpoint — per document:
`POST /rollback/{org}/{docid}` with a lib0-encoded body containing
`{"by": "<userid>"}` (see yhub API.md "Rollback"), authenticated as a
user with update ability on the document.
"""
logger.info(
"reset_connections is a no-op (no yhub kick API), document %s, user %s",
document_id,
user_id,
)
# pylint: disable=unused-argument
def get_document_connection_info(self, room, session_key):
"""
Get the connection info for a document.
TODO(yhub): yhub exposes no connection-info API, so pretend nobody is
connected. Callers fall back to the cache-lock no-websocket path.
"""
return 0, False
-14
View File
@@ -1,14 +0,0 @@
"""Tasks dedicated to document's accesses."""
from core.services.collaboration_services import CollaborationService
from impress.celery_app import app
@app.task
def reset_service_connections_in_cascade(document_id, user_id=None):
"""
For a given document_id, reset the connections of the document and all its
descendants by delegating to the CollaborationService.
"""
CollaborationService().reset_connections(document_id, user_id)
@@ -4,7 +4,6 @@ Test document accesses API endpoints for users in impress's core app.
# pylint: disable=too-many-lines
import random
from contextlib import contextmanager
from unittest import mock
from uuid import uuid4
@@ -19,25 +18,6 @@ from core.utils.analytics import PosthogEventName
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_reset_connections")
def mock_reset_connections_fixture():
"""
Provide a context manager that patches the ``reset_service_connections_in_cascade``
Celery task and asserts its ``delay`` method is called exactly once for the given
document and user when leaving the context.
"""
@contextmanager
def _mock_reset_connections(document_id, user_id=None):
with mock.patch(
"core.api.viewsets.reset_service_connections_in_cascade.delay"
) as mock_delay:
yield mock_delay
mock_delay.assert_called_once_with(str(document_id), user_id)
return _mock_reset_connections
def test_api_document_accesses_list_anonymous():
"""Anonymous users should not be allowed to list document accesses."""
document = factories.DocumentFactory()
@@ -754,7 +734,6 @@ def test_api_document_accesses_update_administrator_except_owner(
create_for,
via,
mock_user_teams,
mock_reset_connections,
):
"""
A user who is a direct administrator in a document should be allowed to update a user
@@ -793,13 +772,12 @@ def test_api_document_accesses_update_administrator_except_owner(
for field, value in new_values.items():
new_data = {**old_values, field: value}
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -864,7 +842,6 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
def test_api_document_accesses_update_administrator_to_owner(
via,
mock_user_teams,
mock_reset_connections,
):
"""
A user who is an administrator in a document, should not be allowed to update
@@ -912,13 +889,12 @@ def test_api_document_accesses_update_administrator_to_owner(
assert response.status_code == 403
else:
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -931,7 +907,6 @@ def test_api_document_accesses_update_owner(
create_for,
via,
mock_user_teams,
mock_reset_connections,
):
"""
A user who is an owner in a document should be allowed to update
@@ -968,14 +943,13 @@ def test_api_document_accesses_update_owner(
for field, value in new_values.items():
new_data = {**old_values, field: value}
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -994,7 +968,6 @@ def test_api_document_accesses_update_owner(
def test_api_document_accesses_update_owner_self_root(
via,
mock_user_teams,
mock_reset_connections,
):
"""
A user who is owner of a document should be allowed to update
@@ -1033,30 +1006,27 @@ def test_api_document_accesses_update_owner_self_root(
# Add another owner and it should now work
factories.UserDocumentAccessFactory(document=document, role="owner")
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self_child(
via,
mock_user_teams,
mock_reset_connections,
):
"""
A user who is owner of a document should be allowed to update
@@ -1084,13 +1054,11 @@ def test_api_document_accesses_update_owner_self_child(
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
@@ -1170,7 +1138,6 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
def test_api_document_accesses_delete_administrators_except_owners(
via,
mock_user_teams,
mock_reset_connections,
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -1199,14 +1166,13 @@ def test_api_document_accesses_delete_administrators_except_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
with mock_reset_connections(document.id, str(access.user_id)):
with mock.patch("core.api.viewsets.posthog_capture") as mock_capture:
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock.patch("core.api.viewsets.posthog_capture") as mock_capture:
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
# The access deletion should be tracked in PostHog
mock_capture.assert_called_once_with(
@@ -1255,7 +1221,6 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
def test_api_document_accesses_delete_owners(
via,
mock_user_teams,
mock_reset_connections,
):
"""
Users should be able to delete the document access of another user
@@ -1280,11 +1245,10 @@ def test_api_document_accesses_delete_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
with mock_reset_connections(document.id, str(access.user_id)):
with mock.patch("core.api.viewsets.posthog_capture") as mock_capture:
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock.patch("core.api.viewsets.posthog_capture") as mock_capture:
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@@ -1327,9 +1291,7 @@ def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_team
assert models.DocumentAccess.objects.count() == 2
def test_api_document_accesses_delete_owners_last_owner_child_user(
mock_reset_connections,
):
def test_api_document_accesses_delete_owners_last_owner_child_user():
"""
It should be possible to delete the last owner access from a document that is not a root.
"""
@@ -1345,10 +1307,9 @@ def test_api_document_accesses_delete_owners_last_owner_child_user(
)
assert models.DocumentAccess.objects.count() == 2
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@@ -1359,7 +1320,6 @@ def test_api_document_accesses_delete_owners_last_owner_child_user(
)
def test_api_document_accesses_delete_owners_last_owner_child_team(
mock_user_teams,
mock_reset_connections,
):
"""
It should be possible to delete the last owner access from a document that
@@ -1378,10 +1338,9 @@ def test_api_document_accesses_delete_owners_last_owner_child_team(
)
assert models.DocumentAccess.objects.count() == 2
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@@ -1,203 +0,0 @@
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
"""Anonymous users can edit documents when link_role is editor."""
document = factories.DocumentFactory(link_reach="public", link_role=role)
client = APIClient()
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = ws_not_connected_ready_only
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
if role == "reader":
assert response.status_code == 401
else:
assert response.status_code == 200
assert response.json() == {"can_edit": True}
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
def test_api_documents_can_edit_authenticated_no_websocket(
settings, ws_not_connected_ready_only
):
"""
A user not connected to the websocket and no other user have already updated the document,
the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = ws_not_connected_ready_only
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
settings,
):
"""
A user not connected to the websocket and another user have already updated the document,
the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
# TODO(yhub): removed test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block edition.
# Re-add the test once yhub exposes a connection-info API.
def test_api_documents_can_edit_user_connected_to_websocket(settings):
"""
A user connected to the websocket, the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert cache.get(f"docs:no-websocket:{document.id}") is None
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
"""
When the websocket server returns a 404, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
"""
When the websocket server returns a 404 and another user is editing the document,
the response should be can-edit=False.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
@@ -6,7 +6,6 @@ import base64
from functools import cache
from uuid import uuid4
from django.core.cache import cache as django_cache
from django.core.files.storage import default_storage
import pycrdt
@@ -101,7 +100,7 @@ def test_api_documents_content_update_success(role, via, mock_user_teams):
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": True},
{"content": get_sample_ydoc()},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@@ -180,7 +179,7 @@ def test_api_documents_content_update_replaces_existing():
new_content = get_sample_ydoc()
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": new_content, "websocket": True},
{"content": new_content},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@@ -245,7 +244,7 @@ def test_api_documents_content_update_link_editor():
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": True},
{"content": get_sample_ydoc()},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@@ -253,213 +252,6 @@ def test_api_documents_content_update_link_editor():
assert models.Document.objects.filter(id=document.id).exists()
def test_api_documents_content_update_authenticated_no_websocket(settings):
"""
When a user updates the document content, not connected to the websocket and is the first
to update, the content should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_content_update_authenticated_no_websocket_user_already_editing(
settings,
):
"""
When a user updates the document content, not connected to the websocket and another session
is already editing, the update should be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {"detail": "You are not allowed to edit this document."}
# TODO(yhub): removed
# test_api_documents_content_update_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block the update.
# Re-add the test once yhub exposes a connection-info API.
def test_api_documents_content_update_user_connected_to_websocket(settings):
"""
When a user updates document content and is connected to the websocket,
the content should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even though the user is connected.
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_content_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the content should be updated like if the user
was not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If another user is already editing, the content update should be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_content_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the update must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_documents_content_update_force_websocket_param_to_true(settings):
"""
When the websocket parameter is set to true, the content should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": True},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
def test_api_documents_content_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the content should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
def test_api_documents_content_upadte_invalid_yjs_doc():
"""sending an invalid yjs doc as content should return a 400."""
user = factories.UserFactory()
@@ -473,10 +265,7 @@ def test_api_documents_content_upadte_invalid_yjs_doc():
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{
"content": base64.b64encode(b"invalid yjs").decode("utf-8"),
"websocket": True,
},
{"content": base64.b64encode(b"invalid yjs").decode("utf-8")},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -1,8 +1,5 @@
"""Tests for link configuration of documents on API endpoint"""
from contextlib import contextmanager
from unittest import mock
import pytest
from rest_framework.test import APIClient
@@ -13,25 +10,6 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_reset_connections")
def mock_reset_connections_fixture():
"""
Provide a context manager that patches the ``reset_service_connections_in_cascade``
Celery task and asserts its ``delay`` method is called exactly once for the given
document when leaving the context.
"""
@contextmanager
def _mock_reset_connections(document_id):
with mock.patch(
"core.api.viewsets.reset_service_connections_in_cascade.delay"
) as mock_delay:
yield mock_delay
mock_delay.assert_called_once_with(str(document_id))
return _mock_reset_connections
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_anonymous(reach, role):
@@ -141,7 +119,6 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
via,
role,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is administrator or owner of a document should be allowed to update
@@ -171,18 +148,17 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
)
).data
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
def test_api_documents_link_configuration_update_role_restricted_forbidden():
@@ -254,9 +230,7 @@ def test_api_documents_link_configuration_update_link_reach_required():
assert "This field is required" in response.json()["link_reach"][0]
def test_api_documents_link_configuration_update_restricted_without_role_success(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
def test_api_documents_link_configuration_update_restricted_without_role_success():
"""
Test that setting link_reach to restricted without specifying link_role succeeds.
"""
@@ -278,16 +252,15 @@ def test_api_documents_link_configuration_update_restricted_without_role_success
"link_reach": models.LinkReachChoices.RESTRICTED,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.RESTRICTED
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.RESTRICTED
@pytest.mark.parametrize(
@@ -297,7 +270,6 @@ def test_api_documents_link_configuration_update_restricted_without_role_success
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
reach,
role,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting non-restricted link_reach with valid link_role succeeds.
@@ -320,17 +292,16 @@ def test_api_documents_link_configuration_update_non_restricted_with_valid_role_
"link_role": role,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == reach
assert document.link_role == role
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == reach
assert document.link_role == role
def test_api_documents_link_configuration_update_with_ancestor_constraints():
@@ -33,7 +33,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -114,7 +113,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -225,7 +223,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
@@ -313,7 +310,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
@@ -513,7 +509,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role not in ["reader", "commenter"],
"can_edit": access.role not in ["reader", "commenter"],
"children_create": access.role not in ["reader", "commenter"],
"children_list": True,
"collaboration_auth": True,
@@ -83,7 +83,6 @@ def test_api_documents_trashbin_format():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
@@ -151,7 +150,6 @@ def test_api_documents_trashbin_format():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
@@ -7,7 +7,6 @@ import random
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
@@ -47,7 +46,6 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -96,7 +94,6 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory(),
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -148,7 +145,6 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory(),
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -216,7 +212,6 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -269,7 +264,6 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -303,265 +297,6 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
assert value == new_document_values[key]
def test_api_documents_update_authenticated_no_websocket(settings):
"""
When a user updates the document, not connected to the websocket and is the first to update,
the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
"""
When a user updates the document, not connected to the websocket and is not the first to update,
the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
# TODO(yhub): removed test_api_documents_update_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block the update.
# Re-add the test once yhub exposes a connection-info API.
def test_api_documents_update_user_connected_to_websocket(settings):
"""
When a user updates the document, connected to the websocket, the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even though the user is connected.
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document should be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the update must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_documents_update_force_websocket_param_to_true():
"""
When the websocket parameter is set to true, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
def test_api_documents_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
@@ -592,7 +327,6 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{other_document.id!s}/",
new_document_values,
@@ -728,7 +462,7 @@ def test_api_documents_patch_anonymous_or_authenticated_unrelated(
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title", "websocket": True},
{"title": "new title"},
format="json",
)
assert response.status_code == 200
@@ -832,7 +566,7 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title", "websocket": True},
{"title": "new title"},
format="json",
)
assert response.status_code == 200
@@ -857,248 +591,6 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
assert document_values[key] == old_document_values[key]
def test_api_documents_patch_authenticated_no_websocket(settings):
"""
When a user patches the document, not connected to the websocket and is the first to update,
the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_patch_authenticated_no_websocket_user_already_editing(settings):
"""
When a user patches the document, not connected to the websocket and is not the first to
update, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
# TODO(yhub): removed test_api_documents_patch_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block the patch.
# Re-add the test once yhub exposes a connection-info API.
def test_api_documents_patch_user_connected_to_websocket(settings):
"""
When a user patches the document while connected to the websocket, the document should be
updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.title == "new title"
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even though the user is connected.
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the patch should be applied like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior falls back to no-websocket.
If another user is already editing, the patch must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the patch must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
def test_api_documents_patch_force_websocket_param_to_true():
"""
When the websocket parameter is set to true, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title", "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
def test_api_documents_patch_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_teams):
"""
@@ -1141,7 +633,7 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
)
def test_api_documents_patch_empty_body(settings):
def test_api_documents_patch_empty_body():
"""
Test when data is empty the document should not be updated.
The `updated_at` property should not change asserting that no update in the database is made.
@@ -1150,15 +642,10 @@ def test_api_documents_patch_empty_body(settings):
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "owner")], creator=user)
document_updated_at = document.updated_at
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_document_values = serializers.DocumentSerializer(instance=document).data
with patch("core.models.Document.save") as mock_document_save:
@@ -1173,6 +660,3 @@ def test_api_documents_patch_empty_body(settings):
new_document_values = serializers.DocumentSerializer(instance=document).data
assert new_document_values == old_document_values
assert document_updated_at == document.updated_at
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even for an empty body.
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
@@ -6,8 +6,6 @@ because the resource server viewsets inherit from the api viewsets.
"""
from unittest.mock import patch
from django.test import override_settings
import pytest
@@ -61,9 +59,8 @@ def test_external_api_documents_link_configuration_not_allowed(
},
},
)
@patch("core.api.viewsets.reset_service_connections_in_cascade.delay")
def test_external_api_documents_link_configuration_can_be_allowed(
mock_reset, user_token, resource_server_backend, user_specific_sub
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to update the link configuration of a document
@@ -101,6 +98,3 @@ def test_external_api_documents_link_configuration_can_be_allowed(
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.PUBLIC
assert document.link_role == models.LinkRoleChoices.EDITOR
# the collaboration server should be notified through the Celery task
mock_reset.assert_called_once_with(str(document.id))
@@ -25,7 +25,6 @@ pytestmark = pytest.mark.django_db
AI_FEATURE_LEGACY_ENABLED=False,
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY=True,
COLLABORATION_WS_INACTIVITY_TIMEOUT=300,
CONVERSION_UPLOAD_ENABLED=False,
FRONTEND_CSS_URL="http://testcss/",
@@ -56,7 +55,6 @@ def test_api_config(is_authenticated):
"AI_FEATURE_LEGACY_ENABLED": False,
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READ_ONLY": True,
"COLLABORATION_WS_INACTIVITY_TIMEOUT": 300,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
"CONVERSION_FILE_MAX_SIZE": 20971520,
@@ -159,7 +159,6 @@ def test_models_documents_get_abilities_forbidden(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
@@ -228,7 +227,6 @@ def test_models_documents_get_abilities_reader(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -302,7 +300,6 @@ def test_models_documents_get_abilities_commenter(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -373,7 +370,6 @@ def test_models_documents_get_abilities_editor(
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"attachment_upload": True,
"can_edit": True,
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
@@ -433,7 +429,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -479,7 +474,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
@@ -529,7 +523,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -589,7 +582,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -656,7 +648,6 @@ def test_models_documents_get_abilities_reader_user(
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
@@ -726,7 +717,6 @@ def test_models_documents_get_abilities_commenter_user(
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
@@ -791,7 +781,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -1,30 +0,0 @@
"""
This module contains tests for the CollaborationService class in the
core.services.collaboration_services module.
"""
import responses
from core.services.collaboration_services import CollaborationService
def test_reset_connections_makes_no_http_call():
"""
TODO(yhub): yhub has no kick API, so reset_connections is a no-op. It must
neither make any HTTP call nor raise, even without any collaboration
settings configured.
"""
with responses.RequestsMock():
CollaborationService().reset_connections("document-id")
CollaborationService().reset_connections("document-id", user_id="user-id")
def test_get_document_connection_info_makes_no_http_call():
"""
TODO(yhub): yhub has no connection-info API, so get_document_connection_info
always reports nobody connected, without making any HTTP call.
"""
with responses.RequestsMock():
assert CollaborationService().get_document_connection_info(
"room", "session-key"
) == (0, False)
@@ -1,31 +0,0 @@
"""
Tests for the `reset_service_connections_in_cascade` Celery task in the
core.tasks.access module.
"""
from unittest import mock
from core.tasks.access import reset_service_connections_in_cascade
@mock.patch("core.tasks.access.CollaborationService")
def test_reset_service_connections_delegates_to_service(mock_service):
"""
The task should delegate the whole reset to the CollaborationService,
forwarding both the document id and the user id.
"""
reset_service_connections_in_cascade("document-id", "user-id")
mock_service.return_value.reset_connections.assert_called_once_with(
"document-id", "user-id"
)
@mock.patch("core.tasks.access.CollaborationService")
def test_reset_service_connections_defaults_user_id_to_none(mock_service):
"""When no user id is provided, the task should forward None to the service."""
reset_service_connections_in_cascade("document-id")
mock_service.return_value.reset_connections.assert_called_once_with(
"document-id", None
)
-25
View File
@@ -521,28 +521,9 @@ class Base(Configuration):
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
# TODO(yhub): unused since the yhub migration — yhub has no management API
# (reset-connections / get-connections). Kept until a yhub kick and
# connection-info API exist and CollaborationService is reinstated.
COLLABORATION_API_URL = values.Value(
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
)
# TODO(yhub): unused since the yhub migration, see COLLABORATION_API_URL.
COLLABORATION_SERVER_SECRET = SecretFileValue(
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
)
COLLABORATION_WS_URL = values.Value(
None, environ_name="COLLABORATION_WS_URL", environ_prefix=None
)
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = values.BooleanValue(
default=values.BooleanValue( # COLLABORATION_WS_NOT_CONNECTED_READY_ONLY compat
default=False,
environ_name="COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
environ_prefix=None,
),
environ_name="COLLABORATION_WS_NOT_CONNECTED_READ_ONLY",
environ_prefix=None,
)
COLLABORATION_WS_INACTIVITY_TIMEOUT = values.IntegerValue(
None,
environ_name="COLLABORATION_WS_INACTIVITY_TIMEOUT",
@@ -992,12 +973,6 @@ class Base(Configuration):
environ_prefix=None,
)
NO_WEBSOCKET_CACHE_TIMEOUT = values.Value(
default=120,
environ_name="NO_WEBSOCKET_CACHE_TIMEOUT",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -15,9 +15,6 @@ image:
backend:
replicas: 1
envVars:
COLLABORATION_SERVER_SECRET: my-secret
COLLABORATION_API_URL: https://docs.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: False
CONVERSION_UPLOAD_ENABLED: True
DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
@@ -16,10 +16,7 @@ image:
backend:
replicas: 1
envVars:
COLLABORATION_SERVER_SECRET: my-secret
CONVERSION_UPLOAD_ENABLED: True
COLLABORATION_API_URL: https://{{ .Values.feature }}-docs.{{ .Values.domain }}/collaboration/api/
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: True
DJANGO_CSRF_TRUSTED_ORIGINS: https://{{ .Values.feature }}-docs.{{ .Values.domain }}
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: {{ .Values.feature }}-docs.{{ .Values.domain }}