diff --git a/.gitignore b/.gitignore
index 61a7f1ce2..08dfcc4b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,9 @@ env.d/terraform
compose.override.yml
docker/auth/*.local
+# yhub server local install
+src/yhub-server/node_modules/
+
# npm
node_modules
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac490d64a..42c97c3d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,14 @@ and this project adheres to
### Changed
- ♿️(frontend) use semantic `
` structure in document info card #2379
+- ♻️(collaboration) migrate the collaboration server from hocuspocus to yhub:
+ 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
+- 💥(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
+ collaboration to yhub (follow-up)
## [v5.4.1] - 2026-07-09
diff --git a/Makefile b/Makefile
index 54dd50b4d..fcac8eff2 100644
--- a/Makefile
+++ b/Makefile
@@ -190,6 +190,7 @@ bootstrap-e2e: \
build: cache ?=
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
+ @$(MAKE) build-yhub cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
@@ -199,9 +200,14 @@ build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
+build-yhub: cache ?=
+build-yhub: ## build the yhub collaboration server container
+ @$(COMPOSE) build yhub $(cache)
+.PHONY: build-yhub
+
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
- @$(COMPOSE) build y-provider-development $(cache)
+ @$(COMPOSE) build y-provider-development-converter $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
@@ -212,8 +218,9 @@ build-frontend: ## build the frontend container
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
+ @$(MAKE) build-yhub cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
- @$(COMPOSE_E2E) build y-provider $(cache)
+ @$(COMPOSE_E2E) build y-provider-converter $(cache)
.PHONY: build-e2e
nginx-frontend: ## build the nginx-frontend container
@@ -232,8 +239,8 @@ run-backend: ## Start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
- @$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d y-provider-development-converter
+ @$(COMPOSE) up --force-recreate -d yhub
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
@@ -246,9 +253,7 @@ run:
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
- @$(COMPOSE_E2E) stop y-provider-development
@$(COMPOSE_E2E) up --force-recreate -d frontend
- @$(COMPOSE_E2E) up --force-recreate -d y-provider
@$(COMPOSE_E2E) up --force-recreate -d y-provider-converter
.PHONY: run-e2e
diff --git a/compose-e2e.yml b/compose-e2e.yml
index f918b5cc1..e081b1df3 100644
--- a/compose-e2e.yml
+++ b/compose-e2e.yml
@@ -13,7 +13,7 @@ services:
ports:
- "3000:3000"
- y-provider:
+ y-provider-converter:
user: ${DOCKER_USER:-1000}
build:
context: .
@@ -24,16 +24,3 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
- ports:
- - "4444:4444"
-
- y-provider-converter:
- user: ${DOCKER_USER:-1000}
- image: impress:y-provider-production
- restart: unless-stopped
- env_file:
- - env.d/development/common
- - env.d/development/common.local
- depends_on:
- y-provider:
- condition: service_started
diff --git a/compose.yml b/compose.yml
index fe5732f51..b8ac0bf47 100644
--- a/compose.yml
+++ b/compose.yml
@@ -181,7 +181,7 @@ services:
volumes:
- ".:/app"
- y-provider-development:
+ y-provider-development-converter:
user: ${DOCKER_USER:-1000}
build:
context: .
@@ -192,27 +192,66 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
- ports:
- - "4444:4444"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/y-provider/node_modules
- y-provider-development-converter:
+ yhub-valkey:
+ image: valkey/valkey:alpine
+ # volatile-lru per yhub DEPLOYMENT.md; AOF because valkey is the authoritative store
+ # for updates the worker hasn't persisted yet (up to taskDebounce+minMessageLifetime)
+ command: ["valkey-server", "--maxmemory-policy", "volatile-lru",
+ "--appendonly", "yes", "--appendfsync", "everysec"]
+ volumes:
+ - yhub-valkey-data:/data
+ healthcheck:
+ test: ["CMD", "valkey-cli", "ping"]
+ interval: 1s
+ timeout: 2s
+ retries: 60
+
+ yhub-postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: yhub
+ POSTGRES_PASSWORD: yhub
+ POSTGRES_DB: yhub
+ volumes:
+ - yhub-pgdata:/var/lib/postgresql/data
+ # NOTE: initdb.d only runs on a FRESH volume; schema changes need `podman volume rm`
+ - ./docker/files/yhub/initdb:/docker-entrypoint-initdb.d:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U yhub"]
+ interval: 1s
+ timeout: 2s
+ retries: 60
+ # no published port (Django's postgres already publishes)
+
+ yhub:
user: ${DOCKER_USER:-1000}
- image: impress:y-provider-development
- restart: unless-stopped
+ build:
+ context: ./src/yhub-server
+ dockerfile: Dockerfile
+ target: yhub
+ image: impress:yhub
+ environment:
+ HOME: /tmp # same reason as node-based services above (unmapped uid)
+ PORT: 3002
+ REDIS: redis://yhub-valkey:6379
+ POSTGRES: postgres://yhub:yhub@yhub-postgres:5432/yhub
+ REDIS_PREFIX: yhub
env_file:
- env.d/development/common
- env.d/development/common.local
- volumes:
- - ./src/frontend/:/home/frontend
- - /home/frontend/node_modules
- - /home/frontend/servers/y-provider/node_modules
+ restart: unless-stopped
+ ports:
+ - "3002:3002"
depends_on:
- y-provider-development:
- condition: service_started
+ yhub-valkey:
+ condition: service_healthy
+ yhub-postgres:
+ condition: service_healthy
kc_postgresql:
image: postgres:14.3
@@ -268,3 +307,7 @@ networks:
name: lasuite-network
driver: bridge
external: true
+
+volumes:
+ yhub-pgdata: {}
+ yhub-valkey-data: {}
diff --git a/docker/files/yhub/initdb/01-yhub.sql b/docker/files/yhub/initdb/01-yhub.sql
new file mode 100644
index 000000000..0a28fb3c0
--- /dev/null
+++ b/docker/files/yhub/initdb/01-yhub.sql
@@ -0,0 +1,14 @@
+-- Column-for-column from yhub bin/init-db.js (unquoted identifiers so
+-- case-folding matches yhub's persistence.js queries).
+CREATE TABLE IF NOT EXISTS yhub_ydoc_v1 (
+ org text,
+ docid text,
+ branch text,
+ t text,
+ created INT8,
+ gcDoc bytea,
+ nongcDoc bytea,
+ contentmap bytea,
+ contentids bytea,
+ PRIMARY KEY (org,docid,branch,t)
+);
diff --git a/documentation/system-requirements.md b/documentation/system-requirements.md
index db337d9b2..db36deea8 100644
--- a/documentation/system-requirements.md
+++ b/documentation/system-requirements.md
@@ -89,7 +89,7 @@ Production deployments differ significantly from development environments. The t
| --------- | --------------------- |
| 3000 | Next.js |
| 8071 | Django |
-| 4444 | Y-Provider |
+| 3002 | yhub (collaboration WS) |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
diff --git a/env.d/development/common b/env.d/development/common
index b0aea4e69..ef3a8010b 100644
--- a/env.d/development/common
+++ b/env.d/development/common
@@ -72,12 +72,12 @@ OIDC_RS_ALLOWED_AUDIENCES=""
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# Collaboration
-COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
+# TODO(yhub): no management API yet
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:4444/collaboration/ws/
+COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
diff --git a/env.d/development/common.e2e b/env.d/development/common.e2e
index 6a2131c78..5ad8fd0d8 100644
--- a/env.d/development/common.e2e
+++ b/env.d/development/common.e2e
@@ -1,6 +1,5 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="1000/minute"
-COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="1000/minute"
Y_PROVIDER_API_BASE_URL=http://y-provider-converter:4444/api/
diff --git a/env.d/production.dist/common b/env.d/production.dist/common
index bb289de71..f54f54a37 100644
--- a/env.d/production.dist/common
+++ b/env.d/production.dist/common
@@ -6,4 +6,5 @@ FRONTEND_HOST=frontend
YPROVIDER_HOST=y-provider
BUCKET_NAME=docs-media-storage
REALM_NAME=docs
+# TODO(yhub): route is /ws/docs once prod ingress is migrated
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
\ No newline at end of file
diff --git a/src/backend/core/management/commands/clean_document.py b/src/backend/core/management/commands/clean_document.py
index e7a006ad5..5e041a80c 100644
--- a/src/backend/core/management/commands/clean_document.py
+++ b/src/backend/core/management/commands/clean_document.py
@@ -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='';
+# and drop the `yhub:room:docs::*` redis keys.
+
import logging
from django.conf import settings
diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py
index fa1e1e867..0bf891580 100644
--- a/src/backend/core/services/collaboration_services.py
+++ b/src/backend/core/services/collaboration_services.py
@@ -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": ""}` (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
diff --git a/src/backend/core/tests/documents/test_api_documents_can_edit.py b/src/backend/core/tests/documents/test_api_documents_can_edit.py
index f167f033a..4755bd93b 100644
--- a/src/backend/core/tests/documents/test_api_documents_can_edit.py
+++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py
@@ -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
diff --git a/src/backend/core/tests/documents/test_api_documents_content_update.py b/src/backend/core/tests/documents/test_api_documents_content_update.py
index b7b876147..5184df411 100644
--- a/src/backend/core/tests/documents/test_api_documents_content_update.py
+++ b/src/backend/core/tests/documents/test_api_documents_content_update.py
@@ -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():
diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py
index 29c6d72cf..358753b49 100644
--- a/src/backend/core/tests/documents/test_api_documents_update.py
+++ b/src/backend/core/tests/documents/test_api_documents_update.py
@@ -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
diff --git a/src/backend/core/tests/external_api/test_external_api_documents_accesses.py b/src/backend/core/tests/external_api/test_external_api_documents_accesses.py
index 957b30829..1a26f3456 100644
--- a/src/backend/core/tests/external_api/test_external_api_documents_accesses.py
+++ b/src/backend/core/tests/external_api/test_external_api_documents_accesses.py
@@ -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}/",
)
diff --git a/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py b/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py
index 885862f0f..7c1e6a308 100644
--- a/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py
+++ b/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py
@@ -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(
diff --git a/src/backend/core/tests/test_services_collaboration_services.py b/src/backend/core/tests/test_services_collaboration_services.py
index 35e607d21..1eec2f177 100644
--- a/src/backend/core/tests/test_services_collaboration_services.py
+++ b/src/backend/core/tests/test_services_collaboration_services.py
@@ -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)
diff --git a/src/backend/core/tests/test_tasks_access.py b/src/backend/core/tests/test_tasks_access.py
index d794c6b24..b5368c453 100644
--- a/src/backend/core/tests/test_tasks_access.py
+++ b/src/backend/core/tests/test_tasks_access.py
@@ -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")
diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py
index 7c94fb9f5..b52a760dc 100755
--- a/src/backend/impress/settings.py
+++ b/src/backend/impress/settings.py
@@ -512,9 +512,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
)
diff --git a/src/frontend/apps/e2e/.env b/src/frontend/apps/e2e/.env
index 1da7cdfed..a0bd90c65 100644
--- a/src/frontend/apps/e2e/.env
+++ b/src/frontend/apps/e2e/.env
@@ -1,7 +1,7 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
-COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
+COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
MEDIA_BASE_URL=http://localhost:8083
CUSTOM_SIGN_IN=false
IS_INSTANCE=false
diff --git a/src/frontend/apps/e2e/.env.example b/src/frontend/apps/e2e/.env.example
index 52f7745da..45272cc11 100644
--- a/src/frontend/apps/e2e/.env.example
+++ b/src/frontend/apps/e2e/.env.example
@@ -1,7 +1,7 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
-COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
+COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
MEDIA_BASE_URL=http://localhost:8083
IS_INSTANCE=false
CUSTOM_SIGN_IN=false
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
index e8dcb31ca..0cca72cbb 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
@@ -82,9 +82,9 @@ test.describe('Config', () => {
.click();
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
- return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}`);
+ return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
- expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}`);
+ expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
});
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts
index 9ea0fbd5d..526e25a74 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts
@@ -15,14 +15,10 @@ test.describe('Doc Collaboration', () => {
/**
* We check:
* - connection to the collaborative server
- * - signal of the backend to the collaborative server (connection should close)
- * - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({ page }) => {
- let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
- return webSocket
- .url()
- .includes(`${process.env.COLLABORATION_WS_URL}?room=`);
+ const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
+ return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
await page
@@ -32,42 +28,21 @@ test.describe('Doc Collaboration', () => {
})
.click();
- let webSocket = await webSocketPromise;
- expect(webSocket.url()).toContain(
- `${process.env.COLLABORATION_WS_URL}?room=`,
- );
+ const webSocket = await webSocketPromise;
+ expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
// Is connected
- let framesentPromise = webSocket.waitForEvent('framesent');
+ const framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
- let framesent = await framesentPromise;
+ const framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
- await page.getByRole('button', { name: 'Share' }).click();
-
- const selectVisibility = page.getByTestId('doc-visibility');
-
- // When the visibility is changed, the ws should close the connection (backend signal)
- const wsClosePromise = webSocket.waitForEvent('close');
-
- await selectVisibility.click();
- await page.getByRole('menuitemradio', { name: 'Connected' }).click();
-
- // Assert that the doc reconnects to the ws
- const wsClose = await wsClosePromise;
- expect(wsClose.isClosed()).toBeTruthy();
-
- // Check the ws is connected again
- webSocket = await page.waitForEvent('websocket', (webSocket) => {
- return webSocket
- .url()
- .includes(`${process.env.COLLABORATION_WS_URL}?room=`);
- });
- framesentPromise = webSocket.waitForEvent('framesent');
- framesent = await framesentPromise;
- expect(framesent.payload).not.toBeNull();
+ // TODO(yhub): re-add the close/reconnect check (the backend closed the
+ // connection when the doc visibility changed) once yhub exposes a kick
+ // API - `reset_connections` is currently a no-op so the server never
+ // closes the connection.
});
test('it cannot edit if viewer but see and can get resources', async ({
@@ -136,20 +111,24 @@ test.describe('Doc Collaboration', () => {
await cleanup();
});
- test('it checks block editing when not connected to collab server', async ({
+ // TODO(yhub): re-enable when yhub exposes a connection-info API - the test
+ // asserts `can_edit=false` while another user is connected to the
+ // collaborative server, but `get_document_connection_info` is currently
+ // stubbed to report no connections.
+ test.skip('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
- * The good port is 4444, but we want to simulate a not connected
+ * The good port is 3002, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
- COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
+ COLLABORATION_WS_URL: 'ws://localhost:5555/ws/docs',
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true,
});
@@ -211,18 +190,14 @@ test.describe('Doc Collaboration', () => {
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
- return webSocket
- .url()
- .includes(`${process.env.COLLABORATION_WS_URL}?room=`);
+ return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
- expect(webSocket.url()).toContain(
- `${process.env.COLLABORATION_WS_URL}?room=`,
- );
+ expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
await verifyDocName(otherPage, childTitle);
@@ -288,9 +263,7 @@ test.describe('Doc Collaboration', () => {
await page.goto('/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
- return webSocket
- .url()
- .includes(`${process.env.COLLABORATION_WS_URL}?room=`);
+ return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
await page
@@ -301,9 +274,7 @@ test.describe('Doc Collaboration', () => {
.click();
let webSocket = await webSocketPromise;
- expect(webSocket.url()).toContain(
- `${process.env.COLLABORATION_WS_URL}?room=`,
- );
+ expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
@@ -332,9 +303,7 @@ test.describe('Doc Collaboration', () => {
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
- return webSocket
- .url()
- .includes(`${process.env.COLLABORATION_WS_URL}?room=`);
+ return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
// Simulate the tab becoming visible again
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index d07fa0d11..d81ea5383 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -43,7 +43,6 @@
"@gouvfr-lasuite/cunningham-react": "*",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.27.0",
- "@hocuspocus/provider": "3.4.4",
"@lottiefiles/dotlottie-react": "^0.19.6",
"@mantine/core": "9.4.1",
"@mantine/hooks": "9.4.1",
@@ -78,6 +77,7 @@
"use-debounce": "10.1.1",
"uuid": "14.0.1",
"y-protocols": "1.0.7",
+ "y-websocket": "3.0.0",
"yjs": "*",
"zod": "4.4.3",
"zustand": "5.0.14"
diff --git a/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx b/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx
index b06683729..feee4ab03 100644
--- a/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx
+++ b/src/frontend/apps/impress/src/core/config/hooks/useCollaborationUrl.tsx
@@ -7,11 +7,12 @@ export const useCollaborationUrl = (room?: string) => {
return;
}
- const base =
+ // The room is appended to the base URL by the provider (y-websocket)
+ return (
conf?.COLLABORATION_WS_URL ||
(typeof window !== 'undefined'
- ? `wss://${window.location.host}/collaboration/ws/`
- : '');
-
- return `${base}?room=${room}`;
+ ? // TODO(yhub): no prod ingress route yet
+ `wss://${window.location.host}/ws/docs`
+ : '')
+ );
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts b/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts
index 1a6502241..5903d3976 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts
@@ -30,13 +30,13 @@ export function useComments(
canComment,
config?.REACTIONS_MAX_PER_COMMENT ?? 0,
),
- provider?.document,
+ provider?.doc,
);
}, [
docId,
canComment,
provider?.awareness,
- provider?.document,
+ provider?.doc,
user?.full_name,
config?.REACTIONS_MAX_PER_COMMENT,
]);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx
index 3dbbb33fb..e27870b94 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx
@@ -28,8 +28,8 @@ vi.mock('../../doc-management', async () => {
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
useProviderStore: () => ({
provider: {
- configuration: { name: 'test-doc-id' },
- document: {
+ roomname: 'test-doc-id',
+ doc: {
getXmlFragment: () => null,
},
},
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
index 5c9b2b1e2..86a013fec 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
@@ -17,11 +17,10 @@ import {
ThreadsSidebar,
useCreateBlockNote,
} from '@blocknote/react';
-import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
-import type { Awareness } from 'y-protocols/awareness';
+import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -85,7 +84,7 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
interface BlockNoteEditorProps {
doc: Doc;
- provider: HocuspocusProvider;
+ provider: WebsocketProvider;
}
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
@@ -93,7 +92,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const refEditorContainer = useRef(null);
- useSaveDoc(doc.id, provider.document);
+ useSaveDoc(doc.id, provider.doc);
const { i18n, t } = useTranslation();
const langLocalesBN =
@@ -150,8 +149,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
- provider: provider as { awareness?: Awareness | undefined },
- fragment: provider.document.getXmlFragment('document-store'),
+ provider,
+ fragment: provider.doc.getXmlFragment('document-store'),
user: {
name: cursorName,
color: randomColor(),
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx
index 96626f5c3..f24db166b 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx
@@ -142,16 +142,10 @@ interface DocCoreEditorProps {
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
- const showContent = !!(
- isProviderReady && provider?.configuration.name === doc.id
- );
+ const showContent = !!(isProviderReady && provider?.roomname === doc.id);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
- if (
- skeletonVisible ||
- !isProviderReady ||
- provider?.configuration.name !== doc.id
- ) {
+ if (skeletonVisible || !isProviderReady || provider?.roomname !== doc.id) {
return (
{
if (readOnly) {
return (
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx
index e32c41515..150b7c07b 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx
@@ -56,6 +56,9 @@ export const useCollaboration = (room: string) => {
* When the provider detects a lost connection, we invalidate the document query to trigger a refetch.
* Because it can be because the user has access to the document that are modified
* (e.g., permissions changed, document deleted, user removed)
+ * TODO(yhub): this invalidation used to ride on the server-side kick
+ * (reset-connections); without a kick API a permission change no longer
+ * triggers a refetch until the connection drops for another reason.
*/
useEffect(() => {
if (hasLostConnection && room) {
@@ -71,7 +74,7 @@ export const useCollaboration = (room: string) => {
* when the document visibility changes.
*/
useEffect(() => {
- if (!room || broadcastProvider?.document?.guid !== room) {
+ if (!room || broadcastProvider?.doc.guid !== room) {
return;
}
@@ -80,7 +83,7 @@ export const useCollaboration = (room: string) => {
queryKey: [KEY_DOC, { id: room }],
});
});
- }, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
+ }, [addTask, room, queryClient, broadcastProvider?.doc.guid]);
/**
* Set the provider when the collaboration URL and the document content are available.
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx
index b6ceb0230..2edd76424 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx
@@ -1,5 +1,6 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
@@ -49,22 +50,19 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
) => {
/**
* When the AI edit the doc transaction.local is false,
- * so we check if the origin constructor to know where
+ * so we check the transaction origin to know where
* the transaction comes from.
- * "PluginKey" constructor comes from the current user, but transaction.local is more reliable
- * "HocuspocusProvider" constructor comes from other users from the collaboration server,
- * it seems quite reliable too.
- * The AI constructor name seems to not be reliable enough, but by deduction if it's not local
+ * "PluginKey" origin comes from the current user, but transaction.local is more reliable
+ * Updates from other users are applied by the collaboration server with
+ * the provider instance as origin, it seems quite reliable too.
+ * The AI origin seems to not be reliable enough, but by deduction if it's not local
* and not from other users, it has to be from the AI.
*
* TODO: see if we can get the local changes from the AI
*/
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- const transactionOrigin = transaction?.origin?.constructor?.name;
- const PROVIDER_ORIGIN_CONSTRUCTOR = 'HocuspocusProvider';
-
const isAIChange =
- !transaction.local && transactionOrigin !== PROVIDER_ORIGIN_CONSTRUCTOR;
+ !transaction.local &&
+ !(transaction.origin instanceof WebsocketProvider);
/**
* notifySubscribers generate a transaction that can be
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx
index 96ff43943..10f616546 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx
@@ -70,14 +70,12 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
mutationFn: async (variables) => {
// Save the document if we can first, to ensure the latest state is duplicated
const canSave =
- variables.canSave &&
- provider &&
- provider.document.guid === variables.docId;
+ variables.canSave && provider && provider.doc.guid === variables.docId;
if (canSave) {
await updateDocContent({
id: variables.docId,
- content: toBase64(Y.encodeStateAsUpdate(provider.document)),
+ content: toBase64(Y.encodeStateAsUpdate(provider.doc)),
});
}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx
index 63791ea88..5a9cf397e 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx
@@ -6,11 +6,13 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
+import { useProviderStore } from '../stores';
import { Doc } from '../types';
export interface UpdateDocParams {
id: Doc['id'];
title?: string;
+ websocket?: boolean;
}
export const updateDoc = async ({
@@ -38,7 +40,16 @@ type UseUpdateDoc = UseMutationOptions & {
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: updateDoc,
+ /**
+ * Tell the backend when we hold a live collaboration connection,
+ * otherwise its no-websocket cache lock blocks the update while
+ * another user is connected.
+ */
+ mutationFn: (params) =>
+ updateDoc({
+ ...(useProviderStore.getState().isSynced ? { websocket: true } : {}),
+ ...params,
+ }),
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx
index 5411cee6f..cba10a638 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx
@@ -1,5 +1,4 @@
-import { CloseEvent } from '@hocuspocus/common';
-import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
+import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -10,12 +9,12 @@ export interface UseCollaborationStore {
providerUrl: string,
storeId: string,
initialDoc?: Base64,
- ) => HocuspocusProvider;
+ ) => WebsocketProvider;
destroyProvider: () => void;
setReady: (value: boolean) => void;
pauseForInactivity: () => void;
resumeFromInactivity: () => void;
- provider: HocuspocusProvider | undefined;
+ provider: WebsocketProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
@@ -33,18 +32,14 @@ const defaultValues = {
isPausedForInactivity: false,
};
-type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
-
/**
* When a massive simultaneous disconnection occurs (e.g. infra restart), all
* clients would reconnect and invalidate their queries at exactly the same
* time, causing a possible DB spike. Adding random jitter spreads these events over a
* time window so the load is absorbed gradually.
*/
-const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_JITTER_MAX_MS = 3000;
-let reconnectTimeout: ReturnType | undefined;
let lostConnectionTimeout: ReturnType | undefined;
export const useProviderStore = create((set, get) => ({
@@ -58,104 +53,54 @@ export const useProviderStore = create((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
- const provider = new HocuspocusProvider({
- url: wsUrl,
- name: storeId,
- document: doc,
- onDisconnect(data) {
- // Skip reconnect when the disconnect was triggered by inactivity:
- // reconnection only happens once the user becomes active again.
- if (get().isPausedForInactivity) {
- return;
- }
-
- // Attempt to reconnect if the disconnection was clean (initiated by the client or server)
- if ((data.event as ExtendedCloseEvent).wasClean) {
- if (data.event.reason === 'No cookies' && data.event.code === 4001) {
- console.error(
- 'Disconnection due to missing cookies. Not attempting to reconnect.',
- );
- void provider.disconnect();
- set({
- isReady: true,
- isConnected: false,
- });
- return;
- }
-
- clearTimeout(reconnectTimeout);
-
- // Jitter spreading for reconnection attempts
- // Math.random() generates a random delay to avoid all clients
- // reconnecting at the same time
- reconnectTimeout = setTimeout(
- () => void provider.connect(),
- RECONNECT_BASE_DELAY_MS + Math.random() * RECONNECT_JITTER_MAX_MS,
- );
- }
- },
- onAuthenticationFailed() {
- set({ isReady: true, isConnected: false });
- },
- onAuthenticated() {
- set({ isReady: true, isConnected: true });
- },
- onStatus: ({ status }) => {
- const isConnected = status === WebSocketStatus.Connected;
- const wasConnected = get().isConnected;
-
- if (isConnected) {
- clearTimeout(lostConnectionTimeout);
- }
- // If we were previously connected and now we're not,
- // we might have lost the connection
- else if (wasConnected && !get().isPausedForInactivity) {
- clearTimeout(lostConnectionTimeout);
- // Jitter spreading for reconnection attempts
- // Math.random() generates a random delay to avoid all clients
- // reconnecting at the same time
- lostConnectionTimeout = setTimeout(
- () => set({ hasLostConnection: true }),
- Math.random() * RECONNECT_JITTER_MAX_MS,
- );
- }
-
- set((state) => {
- /**
- * status === WebSocketStatus.Connected does not mean we are totally connected
- * because authentication can still be in progress and failed
- * So we only update isConnected when we lose the connection
- */
- const connected =
- status !== WebSocketStatus.Connected
- ? {
- isConnected: false,
- }
- : undefined;
-
- return {
- ...connected,
- isReady: state.isReady || status === WebSocketStatus.Disconnected,
- };
- });
- },
- onSynced: ({ state }) => {
- set({ isSynced: state, isReady: true });
- },
- onClose(data) {
- /**
- * Handle the "Reset Connection" event from the server
- * This is triggered when the server wants to reset the connection
- * for clients in the room.
- * A disconnect is made automatically but it takes time to be triggered,
- * so we force the disconnection here.
- */
- if (data.event.code === 1000) {
- provider.disconnect();
- }
- },
+ const provider = new WebsocketProvider(wsUrl, storeId, doc, {
+ // BroadcastChannel would bypass server auth
+ disableBc: true,
+ // The default 2.5s backoff would hammer the backend with auth fetches
+ // on permanently-failing sockets
+ maxBackoffTime: 30000,
+ // Guarantees inbound traffic for y-websocket's 30s no-traffic watchdog
+ resyncInterval: 20000,
});
+ provider.on('status', ({ status }) => {
+ // 'connecting' must be ignored: it fires on every backoff retry.
+ // 'disconnected' is handled via 'connection-close' (it never fires
+ // for sockets that failed to open).
+ if (status === 'connected') {
+ clearTimeout(lostConnectionTimeout);
+ // An open socket means we are authenticated (auth happens at upgrade)
+ set({ isConnected: true, isReady: true });
+ }
+ });
+
+ provider.on('sync', (isSynced: boolean) => {
+ set({ isSynced, isReady: true });
+ });
+
+ // Fires on every close AND every failed connection attempt
+ // (an auth failure surfaces as an upgrade-level 401, close code 1006).
+ provider.on('connection-close', () => {
+ // Skip when the disconnect was triggered by inactivity:
+ // reconnection only happens once the user becomes active again.
+ if (get().isPausedForInactivity) {
+ return;
+ }
+
+ // The editor renders from the last snapshot while y-websocket retries
+ set({ isConnected: false, isReady: true });
+
+ clearTimeout(lostConnectionTimeout);
+ // Jitter spreading: Math.random() generates a random delay to avoid
+ // all clients invalidating their queries at the same time
+ lostConnectionTimeout = setTimeout(
+ () => set({ hasLostConnection: true }),
+ Math.random() * RECONNECT_JITTER_MAX_MS,
+ );
+ });
+
+ // TODO(yhub): re-add kick handling when yhub exposes a kick API (was onClose code 1000).
+
set({
provider,
});
@@ -163,12 +108,19 @@ export const useProviderStore = create((set, get) => ({
return provider;
},
destroyProvider: () => {
- clearTimeout(reconnectTimeout);
- clearTimeout(lostConnectionTimeout);
const provider = get().provider;
if (provider) {
+ /**
+ * destroy() emits 'connection-close' synchronously before removing
+ * listeners, which re-arms lostConnectionTimeout: it must be cleared
+ * after, or a stale "connection lost" banner flashes on the next doc.
+ */
provider.destroy();
+ // y-websocket never destroys the awareness: its interval would leak
+ provider.awareness.destroy();
+ provider.doc.destroy();
}
+ clearTimeout(lostConnectionTimeout);
set(defaultValues);
},
@@ -177,7 +129,6 @@ export const useProviderStore = create((set, get) => ({
if (get().isPausedForInactivity) {
return;
}
- clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: true, hasLostConnection: false });
get().provider?.disconnect();
@@ -188,7 +139,7 @@ export const useProviderStore = create((set, get) => ({
}
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: false });
- void get().provider?.connect();
+ get().provider?.connect();
},
resetLostConnection: () => set({ hasLostConnection: false }),
}));
diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx
index 851c541eb..73b2fb974 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx
@@ -58,11 +58,7 @@ export const ModalConfirmationVersion = ({
return;
}
- revertUpdate(
- provider.document,
- provider.document,
- base64ToYDoc(version.content),
- );
+ revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
threadStore?.refreshThreads();
diff --git a/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx b/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx
index c4a265ac2..085b41303 100644
--- a/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx
+++ b/src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx
@@ -19,8 +19,7 @@ export const RightPanel = () => {
const { setIsPanelOpen, isPanelOpen, activePanel } = useRightPanelStore();
const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
- const isProviderReady =
- isReady && provider && provider?.configuration.name === doc?.id;
+ const isProviderReady = isReady && provider && provider?.roomname === doc?.id;
const { restoreFocus } = useFocusStore();
/**
diff --git a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx
index 3876dcd02..94864410e 100644
--- a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx
+++ b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx
@@ -1,4 +1,4 @@
-import { HocuspocusProvider } from '@hocuspocus/provider';
+import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -6,10 +6,10 @@ interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
- getBroadcastProvider: () => HocuspocusProvider | undefined;
- handleProviderSync: () => void;
- provider?: HocuspocusProvider;
- setBroadcastProvider: (provider: HocuspocusProvider) => void;
+ getBroadcastProvider: () => WebsocketProvider | undefined;
+ handleProviderSync: (isSynced: boolean) => void;
+ provider?: WebsocketProvider;
+ setBroadcastProvider: (provider: WebsocketProvider) => void;
setTask: (
taskLabel: string,
task: Y.Array,
@@ -34,13 +34,18 @@ export const useBroadcastStore = create((set, get) => ({
// Clean up old provider listeners
const oldProvider = get().provider;
if (oldProvider) {
- oldProvider.off('synced', get().handleProviderSync);
+ oldProvider.off('sync', get().handleProviderSync);
}
- provider.on('synced', get().handleProviderSync);
+ provider.on('sync', get().handleProviderSync);
set({ provider });
},
- handleProviderSync: () => {
+ handleProviderSync: (isSynced) => {
+ // 'sync' fires on both edges; only re-register the tasks once synced
+ if (!isSynced) {
+ return;
+ }
+
const tasks = get().tasks;
Object.entries(tasks).forEach(([taskLabel, { action }]) => {
get().addTask(taskLabel, action);
@@ -61,10 +66,16 @@ export const useBroadcastStore = create((set, get) => ({
return;
}
- const task = provider.document.getArray(taskLabel);
+ const task = provider.doc.getArray(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array, action: () => void) => {
+ // Unobserve the previous observer to avoid leaking one per re-registration
+ const previousTask = get().tasks[taskLabel];
+ if (previousTask) {
+ previousTask.task.unobserve(previousTask.observer);
+ }
+
let isInitializing = true;
const observer = (
_event: Y.YArrayEvent,
@@ -102,7 +113,7 @@ export const useBroadcastStore = create((set, get) => ({
cleanupBroadcast: () => {
const provider = get().provider;
if (provider) {
- provider.off('synced', get().handleProviderSync);
+ provider.off('sync', get().handleProviderSync);
}
// Unobserve all document-specific tasks
diff --git a/src/frontend/package.json b/src/frontend/package.json
index a01bbab30..f445acb81 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -46,6 +46,7 @@
"serialize-javascript": "7.0.7",
"typescript": "6.0.3",
"wrap-ansi": "10.0.0",
+ "y-protocols": "1.0.7",
"yjs": "13.6.31"
},
"packageManager": "yarn@1.22.22"
diff --git a/src/frontend/servers/y-provider/__tests__/collaborationBackend.test.ts b/src/frontend/servers/y-provider/__tests__/collaborationBackend.test.ts
deleted file mode 100644
index 17c88cf0b..000000000
--- a/src/frontend/servers/y-provider/__tests__/collaborationBackend.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import axios from 'axios';
-import { describe, expect, test, vi } from 'vitest';
-
-vi.mock('../src/env', () => ({
- COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
- Y_PROVIDER_API_KEY: 'test-yprovider-key',
-}));
-
-describe('CollaborationBackend', () => {
- test('fetchDocument sends X-Y-Provider-Key header', async () => {
- const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
- status: 200,
- data: {
- id: 'test-doc-id',
- abilities: { retrieve: true, update: true },
- },
- });
-
- const { fetchDocument } = await import('@/api/collaborationBackend');
- const documentId = 'test-document-123';
-
- await fetchDocument({ name: documentId }, { cookie: 'test-cookie' });
-
- expect(axiosGetSpy).toHaveBeenCalledWith(
- `http://app-dev:8000/api/v1.0/documents/${documentId}/`,
- expect.objectContaining({
- headers: expect.objectContaining({
- 'X-Y-Provider-Key': 'test-yprovider-key',
- cookie: 'test-cookie',
- }),
- }),
- );
-
- axiosGetSpy.mockRestore();
- });
-
- test('fetchCurrentUser sends X-Y-Provider-Key header', async () => {
- const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
- status: 200,
- data: {
- id: 'test-user-id',
- email: 'test@example.com',
- },
- });
-
- const { fetchCurrentUser } = await import('@/api/collaborationBackend');
-
- await fetchCurrentUser({
- cookie: 'test-cookie',
- origin: 'http://localhost:3000',
- });
-
- expect(axiosGetSpy).toHaveBeenCalledWith(
- 'http://app-dev:8000/api/v1.0/users/me/',
- expect.objectContaining({
- headers: expect.objectContaining({
- 'X-Y-Provider-Key': 'test-yprovider-key',
- cookie: 'test-cookie',
- origin: 'http://localhost:3000',
- }),
- }),
- );
-
- axiosGetSpy.mockRestore();
- });
-});
diff --git a/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts b/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts
deleted file mode 100644
index da11b023c..000000000
--- a/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import request from 'supertest';
-import { describe, expect, test, vi } from 'vitest';
-
-vi.mock('../src/env', async (importOriginal) => {
- return {
- ...(await importOriginal()),
- PORT: 5555,
- COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
- COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
- };
-});
-
-console.error = vi.fn();
-
-import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
-import { hocuspocusServer, initApp } from '@/servers';
-
-describe('Server Tests', () => {
- test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
- const app = initApp();
-
- const response = await request(app)
- .post('/collaboration/api/reset-connections/?room=test-room')
- .set('Origin', origin)
- .set('Authorization', 'wrong-api-key');
-
- expect(response.status).toBe(401);
- expect(response.body).toStrictEqual({
- error: 'Unauthorized: Invalid API Key',
- });
- });
-
- test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => {
- const app = initApp();
-
- const response = await request(app)
- .post('/collaboration/api/reset-connections/')
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key')
- .send({ document_id: 'test-document' });
-
- expect(response.status).toBe(400);
- expect(response.body).toStrictEqual({ error: 'Room name not provided' });
- });
-
- test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => {
- const closeConnectionsMock = vi
- .spyOn(hocuspocusServer.hocuspocus, 'closeConnections')
- .mockResolvedValue();
-
- const app = initApp();
-
- const response = await request(app)
- .post('/collaboration/api/reset-connections?room=test-room')
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key');
-
- expect(response.status).toBe(200);
- expect(response.body).toStrictEqual({ message: 'Connections reset' });
-
- expect(closeConnectionsMock).toHaveBeenCalledOnce();
- });
-});
diff --git a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts
deleted file mode 100644
index 7efe46c3d..000000000
--- a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-import request from 'supertest';
-import { v4 as uuid } from 'uuid';
-import { describe, expect, test, vi } from 'vitest';
-
-vi.mock('../src/env', async (importOriginal) => {
- return {
- ...(await importOriginal()),
- PORT: 5556,
- COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
- COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
- };
-});
-
-console.error = vi.fn();
-
-import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
-import { hocuspocusServer, initApp } from '@/servers';
-
-const apiEndpoint = '/collaboration/api/get-connections/';
-
-describe('Server Tests', () => {
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}?room=test-room`)
- .set('Origin', origin)
- .set('Authorization', 'wrong-api-key');
-
- expect(response.status).toBe(401);
- expect(response.body.error).toBe('Unauthorized: Invalid API Key');
- });
-
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => {
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}`)
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key')
- .send({ document_id: 'test-document' });
-
- expect(response.status).toBe(400);
- expect(response.body.error).toBe('Room name not provided');
- });
-
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => {
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}?room=test-room`)
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key')
- .send({ document_id: 'test-document' });
-
- expect(response.status).toBe(400);
- expect(response.body.error).toBe('Session key not provided');
- });
-
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => {
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key');
-
- expect(response.status).toBe(404);
- expect(response.body.error).toBe('Room not found');
- });
-
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => {
- const document = await hocuspocusServer.hocuspocus.createDocument(
- 'test-room',
- {},
- uuid(),
- { isAuthenticated: true, readOnly: false },
- {},
- );
-
- document.addConnection({
- webSocket: 1,
- context: { sessionKey: 'test-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 2,
- context: { sessionKey: 'other-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 3,
- context: { sessionKey: 'last-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 4,
- context: { sessionKey: 'session-read-only' },
- document: document,
- pongReceived: false,
- readOnly: true,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
-
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key');
-
- expect(response.status).toBe(200);
- expect(response.body).toEqual({
- count: 3,
- exists: true,
- });
- });
-
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => {
- const document = await hocuspocusServer.hocuspocus.createDocument(
- 'test-room',
- {},
- uuid(),
- { isAuthenticated: true, readOnly: false },
- {},
- );
-
- document.addConnection({
- webSocket: 1,
- context: { sessionKey: 'test-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 2,
- context: { sessionKey: 'other-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 3,
- context: { sessionKey: 'last-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 4,
- context: { sessionKey: 'session-read-only' },
- document: document,
- pongReceived: false,
- readOnly: true,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
-
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`)
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key');
-
- expect(response.status).toBe(200);
- expect(response.body).toEqual({
- count: 3,
- exists: false,
- });
- });
-
- test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => {
- const document = await hocuspocusServer.hocuspocus.createDocument(
- 'test-room',
- {},
- uuid(),
- { isAuthenticated: true, readOnly: false },
- {},
- );
-
- document.addConnection({
- webSocket: 1,
- context: { sessionKey: 'test-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 2,
- context: { sessionKey: 'other-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 3,
- context: { sessionKey: 'last-session-key' },
- document: document,
- pongReceived: false,
- readOnly: false,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
- document.addConnection({
- webSocket: 4,
- context: { sessionKey: 'session-read-only' },
- document: document,
- pongReceived: false,
- readOnly: true,
- request: null,
- timeout: 0,
- socketId: uuid(),
- lock: null,
- } as any);
-
- const app = initApp();
-
- const response = await request(app)
- .get(`${apiEndpoint}?room=test-room&sessionKey=session-read-only`)
- .set('Origin', origin)
- .set('Authorization', 'test-secret-api-key');
-
- expect(response.status).toBe(200);
- expect(response.body).toEqual({
- count: 3,
- exists: false,
- });
- });
-});
diff --git a/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts
deleted file mode 100644
index 16d6c929d..000000000
--- a/src/frontend/servers/y-provider/__tests__/hocuspocusWS.test.ts
+++ /dev/null
@@ -1,388 +0,0 @@
-import { Server } from 'node:net';
-
-import {
- HocuspocusProvider,
- HocuspocusProviderWebsocket,
-} from '@hocuspocus/provider';
-import { v1 as uuidv1, v4 as uuidv4 } from 'uuid';
-import {
- afterAll,
- afterEach,
- beforeAll,
- describe,
- expect,
- test,
- vi,
-} from 'vitest';
-import WebSocket from 'ws';
-
-const portWS = 6666;
-
-vi.mock('../src/env', async (importOriginal) => {
- return {
- ...(await importOriginal()),
- PORT: 5559,
- COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
- COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
- COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
- COLLABORATION_LOGGING: 'true',
- };
-});
-
-vi.mock('../src/api/collaborationBackend', () => ({
- fetchCurrentUser: vi.fn(),
- fetchDocument: vi.fn(),
-}));
-
-console.error = vi.fn();
-console.log = vi.fn();
-
-import * as CollaborationBackend from '@/api/collaborationBackend';
-import { COLLABORATION_SERVER_ORIGIN as origin, PORT as port } from '@/env';
-import { promiseDone } from '@/helpers';
-import { hocuspocusServer, initApp } from '@/servers';
-
-describe('Server Tests', () => {
- let server: Server;
-
- afterEach(() => {
- vi.clearAllMocks();
- vi.restoreAllMocks();
- });
-
- beforeAll(async () => {
- server = initApp().listen(port);
- await hocuspocusServer.listen(portWS);
- });
-
- afterAll(() => {
- void hocuspocusServer.destroy();
- server.close();
- });
-
- test('WebSocket connection with bad origin should be closed', () => {
- const { promise, done } = promiseDone();
- const room = uuidv4();
- const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
- headers: {
- Origin: 'http://bad-origin.com',
- },
- });
-
- ws.onclose = () => {
- expect(ws.readyState).toBe(ws.CLOSED);
- done();
- };
-
- return promise;
- });
-
- test('WebSocket connection without cookies header should be closed', () => {
- const { promise, done } = promiseDone();
- const room = uuidv4();
- const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
- headers: {
- Origin: origin,
- },
- });
-
- ws.onclose = () => {
- expect(ws.readyState).toBe(ws.CLOSED);
- done();
- };
-
- return promise;
- });
-
- test('WebSocket connection not allowed if room not matching provider name', () => {
- const { promise, done } = promiseDone();
- const room = uuidv4();
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- maxAttempts: 1,
- });
-
- const providerName = uuidv4();
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: providerName,
- onAuthenticationFailed(data) {
- expect(console.log).toHaveBeenCalledWith(
- expect.any(String),
- ' --- ',
- 'Invalid room name - Probable hacking attempt:',
- providerName,
- room,
- );
-
- wsHocus.stopConnectionAttempt();
- expect(data.reason).toBe('permission-denied');
- wsHocus.webSocket?.close();
- wsHocus.disconnect();
- provider.destroy();
- wsHocus.destroy();
- done();
- },
- });
-
- provider.attach();
-
- return promise;
- });
-
- test('WebSocket connection not allowed if room is not a valid uuid v4', () => {
- const { promise, done } = promiseDone();
- const room = uuidv1();
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- maxAttempts: 1,
- });
-
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: room,
- onAuthenticationFailed: (data) => {
- expect(console.log).toHaveBeenLastCalledWith(
- expect.any(String),
- ' --- ',
- 'Room name is not a valid uuid:',
- room,
- );
-
- wsHocus.stopConnectionAttempt();
- expect(data.reason).toBe('permission-denied');
- wsHocus.webSocket?.close();
- wsHocus.disconnect();
- provider.destroy();
- wsHocus.destroy();
- done();
- },
- });
-
- provider.attach();
-
- return promise;
- });
-
- test('WebSocket connection not allowed if room is not a valid uuid', () => {
- const { promise, done } = promiseDone();
- const room = 'not-a-valid-uuid';
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- maxAttempts: 1,
- });
-
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: room,
- onAuthenticationFailed: (data) => {
- expect(console.log).toHaveBeenLastCalledWith(
- expect.any(String),
- ' --- ',
- 'Room name is not a valid uuid:',
- room,
- );
-
- wsHocus.stopConnectionAttempt();
- expect(data.reason).toBe('permission-denied');
- wsHocus.webSocket?.close();
- wsHocus.disconnect();
- provider.destroy();
- wsHocus.destroy();
- done();
- },
- });
-
- provider.attach();
-
- return promise;
- });
-
- test('WebSocket connection fails if user can not access document', () => {
- const { promise, done } = promiseDone();
-
- const room = uuidv4();
-
- const fetchDocumentMock = vi
- .spyOn(CollaborationBackend, 'fetchDocument')
- .mockRejectedValue(new Error('some error'));
-
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- maxAttempts: 1,
- });
-
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: room,
- onAuthenticationFailed: (data) => {
- expect(console.error).toHaveBeenLastCalledWith(
- '[onConnect]',
- 'Backend error: Unauthorized',
- );
-
- wsHocus.stopConnectionAttempt();
- expect(data.reason).toBe('permission-denied');
- expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
- { name: room },
- expect.any(Object),
- );
- wsHocus.webSocket?.close();
- wsHocus.disconnect();
- provider.destroy();
- wsHocus.destroy();
- done();
- },
- });
-
- provider.attach();
-
- return promise;
- });
-
- test('WebSocket connection fails if user do not have correct retrieve ability', () => {
- const { promise, done } = promiseDone();
-
- const room = uuidv4();
-
- const fetchDocumentMock = vi
- .spyOn(CollaborationBackend, 'fetchDocument')
- .mockResolvedValue({ abilities: { retrieve: false } } as any);
-
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- maxAttempts: 1,
- });
-
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: room,
- onAuthenticationFailed: (data) => {
- expect(console.log).toHaveBeenLastCalledWith(
- expect.any(String),
- ' --- ',
- 'onConnect: Unauthorized to retrieve this document',
- room,
- );
-
- wsHocus.stopConnectionAttempt();
- expect(data.reason).toBe('permission-denied');
- expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
- { name: room },
- expect.any(Object),
- );
- wsHocus.webSocket?.close();
- wsHocus.disconnect();
- provider.destroy();
- wsHocus.destroy();
- done();
- },
- });
-
- provider.attach();
-
- return promise;
- });
-
- [true, false].forEach((canEdit) => {
- test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => {
- const { promise, done } = promiseDone();
-
- const fetchDocumentMock = vi
- .spyOn(CollaborationBackend, 'fetchDocument')
- .mockResolvedValue({
- abilities: { retrieve: true, update: canEdit },
- } as any);
-
- const room = uuidv4();
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- });
-
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: room,
- onConnect: () => {
- void hocuspocusServer.hocuspocus
- .openDirectConnection(room)
- .then((connection) => {
- connection.document?.getConnections().forEach((connection) => {
- expect(connection.readOnly).toBe(!canEdit);
- });
-
- void connection.disconnect();
-
- provider.destroy();
- wsHocus.destroy();
-
- expect(fetchDocumentMock).toHaveBeenCalledWith(
- { name: room },
- expect.any(Object),
- );
-
- done();
- });
- },
- });
-
- provider.attach();
-
- return promise;
- });
- });
-
- test('Add request header x-user-id if found', () => {
- const { promise, done } = promiseDone();
-
- const fetchDocumentMock = vi
- .spyOn(CollaborationBackend, 'fetchDocument')
- .mockResolvedValue({
- abilities: { retrieve: true, update: true },
- } as any);
-
- const fetchCurrentUserMock = vi
- .spyOn(CollaborationBackend, 'fetchCurrentUser')
- .mockResolvedValue({ id: 'test-user-id' } as any);
-
- const room = uuidv4();
- const wsHocus = new HocuspocusProviderWebsocket({
- url: `ws://localhost:${portWS}/?room=${room}`,
- WebSocketPolyfill: WebSocket,
- });
-
- const provider = new HocuspocusProvider({
- websocketProvider: wsHocus,
- name: room,
- onConnect: () => {
- const document = hocuspocusServer.hocuspocus.documents.get(room);
- if (document) {
- document.getConnections().forEach((connection) => {
- expect(connection.context.userId).toBe('test-user-id');
- });
- }
-
- provider.destroy();
- wsHocus.destroy();
-
- expect(fetchDocumentMock).toHaveBeenCalledWith(
- { name: room },
- expect.any(Object),
- );
-
- expect(fetchCurrentUserMock).toHaveBeenCalled();
-
- done();
- },
- });
-
- provider.attach();
-
- return promise;
- });
-});
diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json
index 124271628..798bd9f6d 100644
--- a/src/frontend/servers/y-provider/package.json
+++ b/src/frontend/servers/y-provider/package.json
@@ -18,26 +18,18 @@
"dependencies": {
"@blocknote/core": "0.51.4",
"@blocknote/server-util": "0.51.4",
- "@hocuspocus/server": "3.4.4",
"@sentry/node": "10.65.0",
"@sentry/profiling-node": "10.65.0",
"@tiptap/extensions": "*",
- "axios": "1.18.1",
"cors": "2.8.6",
"express": "5.2.1",
- "express-ws": "5.0.2",
- "uuid": "14.0.1",
- "y-protocols": "1.0.7",
"yjs": "*"
},
"devDependencies": {
- "@hocuspocus/provider": "3.4.4",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
- "@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "7.2.1",
- "@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
@@ -46,8 +38,7 @@
"tsc-alias": "1.9.1",
"typescript": "*",
"vitest": "4.1.10",
- "vitest-mock-extended": "5.0.0",
- "ws": "8.21.0"
+ "vitest-mock-extended": "5.0.0"
},
"packageManager": "yarn@1.22.22"
}
diff --git a/src/frontend/servers/y-provider/src/api/collaborationBackend.ts b/src/frontend/servers/y-provider/src/api/collaborationBackend.ts
deleted file mode 100644
index a9ae76b24..000000000
--- a/src/frontend/servers/y-provider/src/api/collaborationBackend.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { IncomingHttpHeaders } from 'http';
-
-import axios from 'axios';
-
-import { COLLABORATION_BACKEND_BASE_URL, Y_PROVIDER_API_KEY } from '@/env';
-
-export interface User {
- id: string;
- email: string;
- full_name: string;
- short_name: string;
- language: string;
-}
-
-type Base64 = string;
-
-interface Doc {
- id: string;
- title?: string;
- content?: Base64;
- creator: string;
- is_favorite: boolean;
- link_reach: 'restricted' | 'public' | 'authenticated';
- link_role: 'reader' | 'editor';
- nb_accesses_ancestors: number;
- nb_accesses_direct: number;
- created_at: string;
- updated_at: string;
- abilities: {
- accesses_manage: boolean;
- accesses_view: boolean;
- ai_proxy: boolean;
- ai_transform: boolean;
- ai_translate: boolean;
- attachment_upload: boolean;
- children_create: boolean;
- children_list: boolean;
- collaboration_auth: boolean;
- destroy: boolean;
- favorite: boolean;
- invite_owner: boolean;
- link_configuration: boolean;
- media_auth: boolean;
- move: boolean;
- partial_update: boolean;
- restore: boolean;
- retrieve: boolean;
- update: boolean;
- versions_destroy: boolean;
- versions_list: boolean;
- versions_retrieve: boolean;
- };
-}
-
-async function fetch(
- path: string,
- requestHeaders: IncomingHttpHeaders,
-): Promise {
- const response = await axios.get(
- `${COLLABORATION_BACKEND_BASE_URL}${path}`,
- {
- headers: {
- cookie: requestHeaders['cookie'],
- origin: requestHeaders['origin'],
- 'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
- },
- },
- );
-
- if (response.status !== 200) {
- throw new Error(`Failed to fetch ${path}: ${response.statusText}`);
- }
-
- return response.data;
-}
-
-export function fetchDocument(
- { name }: { name: string },
- requestHeaders: IncomingHttpHeaders,
-): Promise {
- return fetch(`/api/v1.0/documents/${name}/`, requestHeaders);
-}
-
-export function fetchCurrentUser(
- requestHeaders: IncomingHttpHeaders,
-): Promise {
- return fetch('/api/v1.0/users/me/', requestHeaders);
-}
diff --git a/src/frontend/servers/y-provider/src/env.ts b/src/frontend/servers/y-provider/src/env.ts
index e125edd90..13dfcb577 100644
--- a/src/frontend/servers/y-provider/src/env.ts
+++ b/src/frontend/servers/y-provider/src/env.ts
@@ -16,5 +16,3 @@ export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY_FILE
: process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
-export const COLLABORATION_BACKEND_BASE_URL =
- process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts
deleted file mode 100644
index 41dfcee0c..000000000
--- a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Request, Response } from 'express';
-
-import { hocuspocusServer } from '@/servers';
-import { logger } from '@/utils';
-
-type ResetConnectionsRequestQuery = {
- room?: string;
-};
-
-export const collaborationResetConnectionsHandler = (
- req: Request