(backend) implement reset-connections and create-ydoc in YHubService

The reset-connections and create-ydoc are the first action we want to
implement in the YHubService. They will be used in next commits.
This commit is contained in:
Manuel Raynaud
2026-09-02 09:36:41 +02:00
parent 48d022c4c0
commit 05190af71f
2 changed files with 152 additions and 14 deletions
+64 -6
View File
@@ -92,6 +92,19 @@ class YHubService:
"""Return the timeout of the requests to the yhub API, in seconds."""
return settings.YHUB_API_TIMEOUT
@property
def user_id(self):
"""
Return the id of the user a call is made on behalf of, if any.
It is the very id yhub knows a user by: the auth plugin resolves the
cookies of a websocket client to the same one.
"""
if self.user is None or not self.user.is_authenticated:
return None
return str(self.user.pk)
@property
def claims(self):
"""
@@ -102,10 +115,10 @@ class YHubService:
rather than to the backend itself. A call made outside of a request,
from a Celery task for instance, has no subject to name.
"""
if self.user is None or not self.user.is_authenticated:
if self.user_id is None:
return {}
return {"sub": str(self.user.pk)}
return {"sub": self.user_id}
@property
def auth_header(self):
@@ -121,14 +134,24 @@ class YHubService:
)
return f"Bearer {token}"
def build_url(self, endpoint, document_id):
def build_url(self, endpoint, document):
"""Build the url of a document scoped endpoint of the yhub API."""
return (
f"{self.base_url}/{self.api_prefix}/{endpoint}/{self.api_version}"
f"/{self.org}/{document_id}"
f"/{self.org}/{document.id}"
)
def request(self, method, url, params=None, data=None):
@staticmethod
def build_user_header(user_id):
"""
Name a user to yhub, or nobody when there is no user to name.
yhub only reads this header from a call authenticated as admin, and
what it does with it depends on the endpoint it is sent to.
"""
return {"X-User-Id": str(user_id)} if user_id else {}
def request(self, method, url, data=None, headers=None):
"""
Send an authenticated request to the yhub API.
@@ -139,11 +162,11 @@ class YHubService:
response = requests.request(
method,
url,
params=params,
data=data,
headers={
"Authorization": self.auth_header,
"Content-Type": "application/octet-stream",
**(headers or {}),
},
timeout=self.timeout,
)
@@ -166,3 +189,38 @@ class YHubService:
)
return response
def create_ydoc(self, document, update):
"""
Seed the initial Yjs state of a document.
The body is the raw binary update, what pycrdt's `get_update()`
returns, and not the lib0 encoding the built-in `ydoc` endpoint speaks.
The content is attributed to the user the service is bound to, yhub
only takes our word for it because the token grants admin.
It is a strict create: yhub answers 409 when the document already has
content, 413 over 10MB and 400 on an update it cannot apply, all
reported as an `APIError` carrying the status.
"""
return self.request(
"post",
self.build_url("create-ydoc", document),
data=update,
headers=self.build_user_header(self.user_id),
)
def reset_connections(self, document, user_id=None):
"""
Re-check the access of the clients connected to a document.
yhub re-runs the authorization of the matching connections and closes
only the ones that lost their access, the others are left alone.
Naming a user restricts the re-check to their own connections, which is
what the change of a single access needs.
"""
return self.request(
"post",
self.build_url("reset-connections", document),
headers=self.build_user_header(user_id),
)
@@ -1,6 +1,7 @@
"""Test yhub services."""
from unittest.mock import patch
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
@@ -8,6 +9,7 @@ import jwt
import pytest
import requests
from core import models
from core.factories import UserFactory
from core.services.jwt_services import Audiences
from core.services.yhub_services import (
@@ -21,6 +23,9 @@ from core.tests.utils.jwt_helper import generate_key_pair
# Generating an RSA key is expensive, do it once for the whole module
PRIVATE_KEY, PUBLIC_KEY = generate_key_pair()
# the service only ever reads the id of the document, no need to save one
DOCUMENT = models.Document(id=uuid4())
@pytest.fixture(autouse=True)
def yhub_settings(settings):
@@ -50,12 +55,9 @@ def test_base_url_strips_trailing_slash(settings):
def test_build_url():
"""A document scoped url should be mounted under the api prefix of yhub."""
url = YHubService().build_url("ydoc", "8c1c8c4d-4b02-4b0f-a0e9-e00cbd1a9a2f")
url = YHubService().build_url("ydoc", DOCUMENT)
assert url == (
"http://yhub:3002/collaboration/ydoc/v1/docs/"
"8c1c8c4d-4b02-4b0f-a0e9-e00cbd1a9a2f"
)
assert url == f"http://yhub:3002/collaboration/ydoc/v1/docs/{DOCUMENT.id!s}"
def test_auth_header():
@@ -105,15 +107,19 @@ def test_request(mock_request):
service = YHubService()
response = service.request(
"get", service.build_url("ydoc", "doc-id"), params={"gc": "false"}
"post", service.build_url("ydoc", DOCUMENT), data=b"body"
)
assert response is mock_request.return_value
args, kwargs = mock_request.call_args
assert args == ("get", "http://yhub:3002/collaboration/ydoc/v1/docs/doc-id")
assert kwargs["params"] == {"gc": "false"}
assert args == (
"post",
f"http://yhub:3002/collaboration/ydoc/v1/docs/{DOCUMENT.id!s}",
)
assert kwargs["data"] == b"body"
assert kwargs["timeout"] == 30
assert kwargs["headers"]["Authorization"].startswith("Bearer ")
assert kwargs["headers"]["Content-Type"] == "application/octet-stream"
@patch("requests.request")
@@ -142,3 +148,77 @@ def test_request_error_status(mock_request):
)
assert excinfo.value.status_code == 403
@patch("requests.request")
def test_create_ydoc(mock_request):
"""Should post the raw update, unencoded, to the create-ydoc endpoint."""
mock_request.return_value.ok = True
update = b"\x01\x02\x03\x04"
response = YHubService().create_ydoc(DOCUMENT, update)
assert response is mock_request.return_value
args, kwargs = mock_request.call_args
assert args == (
"post",
f"http://yhub:3002/collaboration/create-ydoc/v1/docs/{DOCUMENT.id!s}",
)
assert kwargs["data"] == update
# nobody to attribute the content to
assert "X-User-Id" not in kwargs["headers"]
@patch("requests.request")
def test_create_ydoc_attributes_the_content_to_the_user(mock_request):
"""The content should be attributed to the user the service is bound to."""
mock_request.return_value.ok = True
user = UserFactory.build()
YHubService(user=user).create_ydoc(DOCUMENT, b"\x01\x02\x03\x04")
_args, kwargs = mock_request.call_args
assert kwargs["headers"]["X-User-Id"] == str(user.pk)
@patch("requests.request")
def test_create_ydoc_already_exists(mock_request):
"""The strict create of yhub should surface as an APIError carrying the 409."""
mock_request.return_value.ok = False
mock_request.return_value.status_code = 409
mock_request.return_value.text = "Document already exists"
with pytest.raises(APIError) as excinfo:
YHubService().create_ydoc(DOCUMENT, b"\x01\x02\x03\x04")
assert excinfo.value.status_code == 409
@patch("requests.request")
def test_reset_connections(mock_request):
"""Should ask yhub to re-check every connection of the document."""
mock_request.return_value.ok = True
response = YHubService().reset_connections(DOCUMENT)
assert response is mock_request.return_value
args, kwargs = mock_request.call_args
assert args == (
"post",
f"http://yhub:3002/collaboration/reset-connections/v1/docs/{DOCUMENT.id!s}",
)
# no user named: every connection of the document is re-checked
assert "X-User-Id" not in kwargs["headers"]
@patch("requests.request")
def test_reset_connections_of_a_single_user(mock_request):
"""Naming a user should restrict the re-check to their own connections."""
mock_request.return_value.ok = True
user = UserFactory.build()
# the user whose access changed, not the one making the call
YHubService(user=UserFactory.build()).reset_connections(DOCUMENT, user.pk)
_args, kwargs = mock_request.call_args
assert kwargs["headers"]["X-User-Id"] == str(user.pk)