mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-13 13:17:53 +02:00
✨(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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user