♻️(collaboration) switch collaboration server from hocuspocus to yhub

Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-09-01 15:14:54 +02:00
committed by Anthony LC
parent 3646d7dc42
commit a50c824caa
61 changed files with 1219 additions and 2526 deletions
@@ -1,5 +1,13 @@
"""Clean a document by resetting it (keeping its title) and deleting all descendants."""
# TODO(yhub): this sandbox reset no longer erases the document content. It purges
# the S3 versions, but yhub durably retains the Yjs document in its own Postgres
# and re-serves it on the next websocket connect (CRDT merge with the empty
# seed resurrects the purged content). yhub has no delete API; until it grows
# one, the interim remediation is to run, against yhub's stores:
# DELETE FROM yhub_ydoc_v1 WHERE org='docs' AND docid='<document_id>';
# and drop the `yhub:room:docs:<document_id>:*` redis keys.
import logging
from django.conf import settings
@@ -2,102 +2,39 @@
from logging import getLogger
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import requests
from core import models
logger = getLogger(__name__)
class CollaborationService:
"""Service class for Collaboration related operations."""
def __init__(self):
"""Ensure that the collaboration configuration is set properly."""
if settings.COLLABORATION_API_URL is None:
raise ImproperlyConfigured("Collaboration configuration not set")
def reset_connections(self, document_id, user_id=None):
"""
Reset the connections of a document and all its descendants in the
collaboration server.
Resetting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
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.
"""
try:
document = models.Document.objects.get(pk=document_id)
except models.Document.DoesNotExist:
logger.error("Document %s does not exists anymore", document_id)
return
documents = models.Document.objects.filter(
path__startswith=document.path, depth__gte=document.depth
).order_by("path")
for doc in documents:
try:
self._reset_connection(doc.id, user_id)
except requests.HTTPError:
logger.error("impossible to reset connections for document %s", doc.id)
def _reset_connection(self, room, user_id=None):
"""
Reset connections of a single room in the collaboration server.
"""
endpoint = "reset-connections"
# room is necessary as a parameter, it is easier to stick to the
# same pod thanks to a parameter
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
# Note: Collaboration microservice accepts only raw token, which is not recommended
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
if user_id:
headers["X-User-Id"] = user_id
try:
response = requests.post(endpoint_url, headers=headers, timeout=10)
except requests.RequestException as e:
raise requests.HTTPError("Failed to notify WebSocket server.") from e
if response.status_code != 200:
raise requests.HTTPError(
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)
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.
"""
endpoint = "get-connections"
querystring = {
"room": room,
"sessionKey": session_key,
}
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
try:
response = requests.get(
endpoint_url, headers=headers, params=querystring, timeout=10
)
except requests.RequestException as e:
raise requests.HTTPError("Failed to get document connection info.") from e
if response.status_code == 200:
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 404:
return 0, False
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)
return 0, False
@@ -3,7 +3,6 @@
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
@@ -11,22 +10,13 @@ from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
@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()
session_key = client.session.session_key
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
@@ -35,10 +25,8 @@ def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only,
else:
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
@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
@@ -50,19 +38,10 @@ def test_api_documents_can_edit_authenticated_no_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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -72,10 +51,8 @@ def test_api_documents_can_edit_authenticated_no_websocket(
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
settings,
):
@@ -86,18 +63,10 @@ def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -107,45 +76,13 @@ def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
# 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.
@responses.activate
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
A user not connected to the websocket and another user is connected to the websocket,
the document can not 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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
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": False}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_user_connected_to_websocket(settings):
"""
A user connected to the websocket, the document can be updated.
@@ -153,18 +90,10 @@ def test_api_documents_can_edit_user_connected_to_websocket(settings):
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -174,10 +103,8 @@ def test_api_documents_can_edit_user_connected_to_websocket(settings):
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -188,18 +115,10 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -209,10 +128,7 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -223,18 +139,10 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -245,10 +153,8 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
@@ -259,18 +165,10 @@ def test_api_documents_can_edit_websocket_server_room_not_found(
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -280,10 +178,7 @@ def test_api_documents_can_edit_websocket_server_room_not_found(
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
@@ -294,18 +189,10 @@ def test_api_documents_can_edit_websocket_server_room_not_found_other_already_ed
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -314,5 +201,3 @@ def test_api_documents_can_edit_websocket_server_room_not_found_other_already_ed
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@@ -11,7 +11,6 @@ from django.core.files.storage import default_storage
import pycrdt
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
@@ -254,7 +253,6 @@ def test_api_documents_content_update_link_editor():
assert models.Document.objects.filter(id=document.id).exists()
@responses.activate
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
@@ -267,14 +265,7 @@ def test_api_documents_content_update_authenticated_no_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -285,10 +276,8 @@ def test_api_documents_content_update_authenticated_no_websocket(settings):
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
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_content_update_authenticated_no_websocket_user_already_editing(
settings,
):
@@ -299,18 +288,10 @@ def test_api_documents_content_update_authenticated_no_websocket_user_already_ed
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -320,46 +301,15 @@ def test_api_documents_content_update_authenticated_no_websocket_user_already_ed
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_content_update_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
When a user updates document content without websocket and another user is connected
to the websocket, the update should be denied.
"""
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": 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_403_FORBIDDEN
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# 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.
@responses.activate
def test_api_documents_content_update_user_connected_to_websocket(settings):
"""
When a user updates document content and is connected to the websocket,
@@ -372,14 +322,7 @@ def test_api_documents_content_update_user_connected_to_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -389,11 +332,11 @@ def test_api_documents_content_update_user_connected_to_websocket(settings):
)
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
assert ws_resp.call_count == 1
# 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
@responses.activate
def test_api_documents_content_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -408,14 +351,7 @@ def test_api_documents_content_update_websocket_server_unreachable_fallback_to_n
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -426,10 +362,8 @@ def test_api_documents_content_update_websocket_server_unreachable_fallback_to_n
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
assert ws_resp.call_count == 1
@responses.activate
def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -440,18 +374,10 @@ def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocke
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -461,10 +387,8 @@ def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocke
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_content_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
@@ -475,18 +399,10 @@ def test_api_content_update_websocket_server_room_not_found_fallback_to_no_webso
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -496,10 +412,8 @@ def test_api_content_update_websocket_server_room_not_found_fallback_to_no_webso
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
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.
@@ -507,18 +421,10 @@ def test_api_documents_content_update_force_websocket_param_to_true(settings):
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -529,10 +435,8 @@ def test_api_documents_content_update_force_websocket_param_to_true(settings):
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
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_content_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the content should be updated without any check.
@@ -540,18 +444,10 @@ def test_api_documents_content_update_feature_flag_disabled(settings):
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -562,7 +458,6 @@ def test_api_documents_content_update_feature_flag_disabled(settings):
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
assert ws_resp.call_count == 0
def test_api_documents_content_upadte_invalid_yjs_doc():
@@ -10,7 +10,6 @@ from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -304,7 +303,6 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
assert value == new_document_values[key]
@responses.activate
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,
@@ -321,15 +319,7 @@ def test_api_documents_update_authenticated_no_websocket(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -344,10 +334,8 @@ def test_api_documents_update_authenticated_no_websocket(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
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,
@@ -356,7 +344,6 @@ def test_api_documents_update_authenticated_no_websocket_user_already_editing(se
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")])
@@ -364,14 +351,7 @@ def test_api_documents_update_authenticated_no_websocket_user_already_editing(se
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -383,49 +363,13 @@ def test_api_documents_update_authenticated_no_websocket_user_already_editing(se
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
# 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.
@responses.activate
def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user updates the document, not connected to the websocket and another user is connected
to the websocket, the document should not 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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
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."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
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.
@@ -441,14 +385,7 @@ def test_api_documents_update_user_connected_to_websocket(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -462,11 +399,11 @@ def test_api_documents_update_user_connected_to_websocket(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# 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
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -485,14 +422,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -507,10 +437,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -521,7 +449,6 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
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")])
@@ -529,14 +456,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -548,10 +468,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
@@ -562,7 +480,6 @@ def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_web
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")])
@@ -570,14 +487,7 @@ def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_web
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -589,18 +499,15 @@ def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_web
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_force_websocket_param_to_true(settings):
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)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
@@ -608,13 +515,6 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -629,10 +529,8 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the document should be updated without any check.
@@ -640,7 +538,6 @@ def test_api_documents_update_feature_flag_disabled(settings):
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")])
@@ -648,14 +545,7 @@ def test_api_documents_update_feature_flag_disabled(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -670,7 +560,6 @@ def test_api_documents_update_feature_flag_disabled(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA)
@@ -968,7 +857,6 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
assert document_values[key] == old_document_values[key]
@responses.activate
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,
@@ -981,14 +869,7 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1006,10 +887,8 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
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
@@ -1018,18 +897,10 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -1041,45 +912,13 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
# 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.
@responses.activate
def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user patches the document, not connected to the websocket and another user is connected
to the websocket, the document should not 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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
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."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
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
@@ -1092,14 +931,7 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1116,11 +948,11 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
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
assert ws_resp.call_count == 1
# 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
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -1135,14 +967,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1160,10 +985,8 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -1174,18 +997,10 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -1197,10 +1012,8 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
@@ -1211,18 +1024,10 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -1234,29 +1039,18 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_force_websocket_param_to_true(settings):
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)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1273,10 +1067,8 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_patch_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the patch should be applied without any check.
@@ -1284,18 +1076,10 @@ def test_api_documents_patch_feature_flag_disabled(settings):
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_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1313,7 +1097,6 @@ def test_api_documents_patch_feature_flag_disabled(settings):
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA)
@@ -1358,7 +1141,6 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
)
@responses.activate
def test_api_documents_patch_empty_body(settings):
"""
Test when data is empty the document should not be updated.
@@ -1373,14 +1155,7 @@ def test_api_documents_patch_empty_body(settings):
document = factories.DocumentFactory(users=[(user, "owner")], creator=user)
document_updated_at = document.updated_at
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -1398,5 +1173,6 @@ 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
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# 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
@@ -9,7 +9,6 @@ because the resource server viewsets inherit from the api viewsets.
from django.test import override_settings
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -504,7 +503,6 @@ def test_external_api_document_accesses_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
@@ -525,19 +523,6 @@ def test_external_api_document_accesses_update_can_be_allowed(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
# Update only the role field
@@ -573,7 +558,6 @@ def test_external_api_document_accesses_partial_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
@@ -594,19 +578,6 @@ def test_external_api_document_accesses_partial_update_can_be_allowed(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={"role": models.RoleChoices.EDITOR},
@@ -635,7 +606,7 @@ def test_external_api_document_accesses_partial_update_can_be_allowed(
}
)
def test_external_api_documents_accesses_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub, settings
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to delete an access for
@@ -661,19 +632,6 @@ def test_external_api_documents_accesses_delete_can_be_allowed(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{other_access.id!s}/",
)
@@ -60,8 +60,6 @@ def test_external_api_documents_link_configuration_not_allowed(
],
},
},
COLLABORATION_API_URL="http://example.com/",
COLLABORATION_SERVER_SECRET="secret-token",
)
@patch("core.api.viewsets.reset_service_connections_in_cascade.delay")
def test_external_api_documents_link_configuration_can_be_allowed(
@@ -3,342 +3,28 @@ This module contains tests for the CollaborationService class in the
core.services.collaboration_services module.
"""
import json
import logging
import re
from contextlib import contextmanager
from unittest import mock
from uuid import uuid4
from django.core.exceptions import ImproperlyConfigured
import pytest
import requests
import responses
from core import factories, models
from core.services.collaboration_services import CollaborationService
# pylint: disable=protected-access
@pytest.fixture(name="mock_reset_connections")
def mock_reset_connections_fixture(settings):
def test_reset_connections_makes_no_http_call():
"""
Creates a context manager to mock the reset-connections endpoint for collaboration services.
Args:
settings: A settings object that contains the configuration for the collaboration API.
Returns:
A context manager function that mocks the reset-connections endpoint.
The context manager function takes the following parameters:
document_id (str): The ID of the document for which connections are being reset.
user_id (str, optional): The ID of the user making the request. Defaults to None.
Usage:
with mock_reset_connections(settings)(document_id, user_id) as mock:
# Your test code here
The context manager performs the following actions:
- Mocks the reset-connections endpoint using responses.RequestsMock.
- Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings.
- Verifies that the reset-connections endpoint is called exactly once.
- Checks that the request URL and headers are correct.
- If user_id is provided, checks that the X-User-Id header is correct.
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.
"""
@contextmanager
def _mock_reset_connections(document_id, user_id=None):
with responses.RequestsMock() as rsps:
# Mock the reset-connections endpoint
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}"
)
rsps.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
yield
assert len(rsps.calls) == 1, (
"Expected one call to reset-connections endpoint"
)
request = rsps.calls[0].request
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
assert (
request.headers.get("Authorization")
== settings.COLLABORATION_SERVER_SECRET
), "Incorrect Authorization header"
if user_id:
assert request.headers.get("X-User-Id") == user_id, (
"Incorrect X-User-Id header"
)
return _mock_reset_connections
with responses.RequestsMock():
CollaborationService().reset_connections("document-id")
CollaborationService().reset_connections("document-id", user_id="user-id")
def test_init_without_api_url(settings):
"""Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None."""
settings.COLLABORATION_API_URL = None
with pytest.raises(ImproperlyConfigured):
CollaborationService()
def test_init_with_api_url(settings):
"""Test that the service initializes correctly when COLLABORATION_API_URL is set."""
settings.COLLABORATION_API_URL = "http://example.com/"
service = CollaborationService()
assert isinstance(service, CollaborationService)
@responses.activate
def test_reset_connection_with_user_id(settings):
"""Test _reset_connection with a provided user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(responses.POST, endpoint_url, json={}, status=200)
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") == "user123"
@responses.activate
def test_reset_connection_without_user_id(settings):
"""Test _reset_connection without a user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = None
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") is None
@responses.activate
def test_reset_connection_non_200_response(settings):
"""Test that an HTTPError is raised when the response status is not 200."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
response_body = {"error": "Internal Server Error"}
responses.add(responses.POST, endpoint_url, json=response_body, status=500)
expected_exception_message = re.escape(
"Failed to notify WebSocket server. Status code: 500, Response: "
) + re.escape(json.dumps(response_body))
with pytest.raises(requests.HTTPError, match=expected_exception_message):
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
@responses.activate
def test_reset_connection_request_exception(settings):
"""Test that an HTTPError is raised when a RequestException occurs."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections?room=" + room
responses.add(
responses.POST,
endpoint_url,
body=requests.exceptions.ConnectionError("Network error"),
)
with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."):
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
@pytest.fixture(name="collaboration_service")
def collaboration_service_fixture(settings):
"""Return a configured CollaborationService instance."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
return CollaborationService()
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_document_does_not_exist(
mock_reset_connection,
collaboration_service,
caplog,
):
def test_get_document_connection_info_makes_no_http_call():
"""
When the document does not exist anymore, an error is logged and no
connection is reset.
TODO(yhub): yhub has no connection-info API, so get_document_connection_info
always reports nobody connected, without making any HTTP call.
"""
unknown_id = uuid4()
with caplog.at_level(logging.ERROR, logger="core.services.collaboration_services"):
collaboration_service.reset_connections(unknown_id)
mock_reset_connection.assert_not_called()
assert f"Document {unknown_id} does not exists anymore" in caplog.text
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_single_document(
mock_reset_connection,
collaboration_service,
):
"""A document without descendants should have its own connections reset."""
document = factories.DocumentFactory()
collaboration_service.reset_connections(document.id)
mock_reset_connection.assert_called_once_with(document.id, None)
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_cascade_on_document_and_descendants(
mock_reset_connection,
collaboration_service,
):
"""
The document itself and every one of its descendants should be reset,
ordered by path.
"""
root = factories.DocumentFactory()
child1 = factories.DocumentFactory(parent=root)
child2 = factories.DocumentFactory(parent=root)
grandchild = factories.DocumentFactory(parent=child1)
collaboration_service.reset_connections(root.id)
expected_ids = [
doc.id
for doc in models.Document.objects.filter(
path__startswith=root.path, depth__gte=root.depth
).order_by("path")
]
assert set(expected_ids) == {root.id, child1.id, child2.id, grandchild.id}
called_ids = [call.args[0] for call in mock_reset_connection.call_args_list]
assert called_ids == expected_ids
assert mock_reset_connection.call_count == 4
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_starts_from_a_sub_document(
mock_reset_connection,
collaboration_service,
):
"""
When called on a sub-document, only that sub-document and its own
descendants should be reset, not its ancestors or siblings.
"""
root = factories.DocumentFactory()
child = factories.DocumentFactory(parent=root)
sibling = factories.DocumentFactory(parent=root)
grandchild = factories.DocumentFactory(parent=child)
collaboration_service.reset_connections(child.id)
called_ids = {call.args[0] for call in mock_reset_connection.call_args_list}
assert called_ids == {child.id, grandchild.id}
assert root.id not in called_ids
assert sibling.id not in called_ids
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_forwards_user_id(
mock_reset_connection,
collaboration_service,
):
"""The provided user_id should be forwarded to every reset call."""
root = factories.DocumentFactory()
factories.DocumentFactory(parent=root)
user_id = str(uuid4())
collaboration_service.reset_connections(root.id, user_id=user_id)
assert mock_reset_connection.call_count == 2
for call in mock_reset_connection.call_args_list:
assert call.args[1] == user_id
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_continues_on_http_error(
mock_reset_connection,
collaboration_service,
caplog,
):
"""
An HTTPError raised while resetting one document should be logged and must
not prevent the remaining documents from being processed.
"""
root = factories.DocumentFactory()
child1 = factories.DocumentFactory(parent=root)
child2 = factories.DocumentFactory(parent=root)
ordered_docs = list(
models.Document.objects.filter(
path__startswith=root.path, depth__gte=root.depth
).order_by("path")
)
failing_doc = ordered_docs[1]
def _side_effect(room, _user_id=None):
if room == failing_doc.id:
raise requests.HTTPError("boom")
mock_reset_connection.side_effect = _side_effect
with caplog.at_level(logging.ERROR, logger="core.services.collaboration_services"):
collaboration_service.reset_connections(root.id)
assert mock_reset_connection.call_count == 3
called_ids = [call.args[0] for call in mock_reset_connection.call_args_list]
assert set(called_ids) == {root.id, child1.id, child2.id}
assert (
f"impossible to reset connections for document {failing_doc.id}" in caplog.text
)
with responses.RequestsMock():
assert CollaborationService().get_document_connection_info(
"room", "session-key"
) == (0, False)
@@ -5,10 +5,6 @@ core.tasks.access module.
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
import pytest
from core.tasks.access import reset_service_connections_in_cascade
@@ -33,16 +29,3 @@ def test_reset_service_connections_defaults_user_id_to_none(mock_service):
mock_service.return_value.reset_connections.assert_called_once_with(
"document-id", None
)
@mock.patch(
"core.tasks.access.CollaborationService",
side_effect=ImproperlyConfigured("Collaboration configuration not set"),
)
def test_reset_service_connections_propagates_improperly_configured(mock_service): # pylint: disable=unused-argument
"""
If the collaboration service is not configured, instantiating it raises
ImproperlyConfigured, which should propagate out of the task.
"""
with pytest.raises(ImproperlyConfigured):
reset_service_connections_in_cascade("document-id")
+4
View File
@@ -521,9 +521,13 @@ 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
)