mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 15:43:01 +02:00
Compare commits
9 Commits
websocket/
...
fix/backen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
561c72bec3 | ||
|
|
a2860e8fe6 | ||
|
|
cfd1fd00da | ||
|
|
ed663f2e1e | ||
|
|
99764b8e3e | ||
|
|
37091ca804 | ||
|
|
394fbc5537 | ||
|
|
7df5aba991 | ||
|
|
c464715158 |
@@ -18,6 +18,8 @@ and this project adheres to
|
||||
- ♿️(frontend) structure correctly 5xx error alerts #2128
|
||||
- ♿️(frontend) make doc search result labels uniquely identifiable #2212
|
||||
- ⬆️(backend) upgrade docspec to v3.0.x and adapt converter API #2220
|
||||
- ✨(backend) make forward auth request uri header configurable #2241
|
||||
- ♿️(frontend) fix sidebar resize handle for screen readers #2122
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -28,6 +30,8 @@ and this project adheres to
|
||||
- 🐛(frontend) fix interlinking modal clipping #2213
|
||||
- 🛂(frontend) fix cannot manage member on small screen #2226
|
||||
- 🐛(backend) load jwks url when OIDC_RS_PRIVATE_KEY_STR is set
|
||||
- 🐛(backend) Prevent moving document to its own descendant or self #2208
|
||||
- 🐛(backend) return 400 when restoring a non-deleted document #2225
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -201,6 +205,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) enforce emoji validation for reactions #1965
|
||||
- 🐛(frontend) analytic feature flags problem #1953
|
||||
- 🐛(frontend) fix home collapsing panel #1954
|
||||
- 🐛(frontend) fix disabled color on icon Dropdown #1950
|
||||
|
||||
@@ -91,6 +91,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||
| MEDIA_BASE_URL | | |
|
||||
| MEDIA_AUTH_ORIGINAL_URL_HEADER | Parameter containing the original request URL, as seen at the media auth endpoint, in CGI/WSGI form (HTTP_HEADER_NAME_ALL_CAPS_WITH_UNDERSCORES) | HTTP_X_ORIGINAL_URL |
|
||||
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import emoji
|
||||
import magic
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -875,6 +876,12 @@ class ReactionSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "users"]
|
||||
|
||||
def validate_emoji(self, value):
|
||||
"""Ensure the reaction is a single emoji."""
|
||||
if not emoji.is_emoji(value):
|
||||
raise serializers.ValidationError("Reaction must be a single valid emoji.")
|
||||
return value
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize comments (nested under a thread) with reactions and abilities."""
|
||||
|
||||
@@ -44,6 +44,7 @@ from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
from treebeard.exceptions import InvalidMoveToDescendant
|
||||
|
||||
from core import authentication, choices, enums, models
|
||||
from core.api.filters import remove_accents
|
||||
@@ -961,7 +962,13 @@ class DocumentViewSet(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
document.move(target_document, pos=position)
|
||||
try:
|
||||
document.move(target_document, pos=position)
|
||||
except InvalidMoveToDescendant:
|
||||
return drf.response.Response(
|
||||
{"target_document_id": "Cannot move a document to its own descendant."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Make sure we have at least one owner
|
||||
if (
|
||||
@@ -989,7 +996,10 @@ class DocumentViewSet(
|
||||
Restore a soft-deleted document if it was deleted less than x days ago.
|
||||
"""
|
||||
document = self.get_object()
|
||||
document.restore()
|
||||
try:
|
||||
document.restore()
|
||||
except RuntimeError as err:
|
||||
raise drf.exceptions.ValidationError({"detail": str(err)}) from err
|
||||
|
||||
return drf_response.Response(
|
||||
{"detail": "Document has been successfully restored."},
|
||||
@@ -1752,10 +1762,13 @@ class DocumentViewSet(
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Extracts and parses the original URL from the configured parameter header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
The original url is passed by reverse proxy in the header specified by the
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting.
|
||||
|
||||
For nginx (the default) this is set to HTTP_X_ORIGINAL_URL.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
@@ -1766,9 +1779,14 @@ class DocumentViewSet(
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
logger.debug(
|
||||
"Missing %s header in subrequest. "
|
||||
"Maybe you need to set MEDIA_AUTH_ORIGINAL_URL_HEADER correctly for your ingress"
|
||||
" proxy.",
|
||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
||||
)
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
@@ -2260,7 +2278,7 @@ class DocumentViewSet(
|
||||
GET /api/v1.0/documents/<resource_id>/cors-proxy
|
||||
Act like a proxy to fetch external resources and bypass CORS restrictions.
|
||||
"""
|
||||
url = request.query_params.get("url")
|
||||
url = request.query_params.get("url", "").strip()
|
||||
if not url:
|
||||
return drf.response.Response(
|
||||
{"detail": "Missing 'url' query parameter"},
|
||||
|
||||
@@ -231,9 +231,10 @@ class ReactionFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Reaction
|
||||
skip_postgeneration_save = True
|
||||
|
||||
comment = factory.SubFactory(CommentFactory)
|
||||
emoji = "test"
|
||||
emoji = factory.Faker("emoji")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
|
||||
@@ -644,11 +644,13 @@ def test_create_reaction_anonymous_user_public_document(link_role):
|
||||
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -664,12 +666,14 @@ def test_create_reaction_authenticated_user_public_document():
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -684,17 +688,19 @@ def test_create_reaction_authenticated_user_accessible_public_document():
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
comment=comment, emoji=reaction.emoji, users__in=[user]
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -709,12 +715,14 @@ def test_create_reaction_authenticated_user_connected_document_link_role_reader(
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -737,17 +745,19 @@ def test_create_reaction_authenticated_user_connected_document(link_role):
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
comment=comment, emoji=reaction.emoji, users__in=[user]
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -760,12 +770,14 @@ def test_create_reaction_authenticated_user_restricted_accessible_document():
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -781,12 +793,14 @@ def test_create_reaction_authenticated_user_restricted_accessible_document_role_
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -806,26 +820,70 @@ def test_create_reaction_authenticated_user_restricted_accessible_document_role_
|
||||
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
comment=comment, emoji=reaction.emoji, users__in=[user]
|
||||
).exists()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"user_already_reacted": True}
|
||||
|
||||
|
||||
def test_create_reaction_invalid_emoji():
|
||||
"""Users should not be able to submit non-emojis as reactions."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.RoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"user_already_reacted": True}
|
||||
assert "Reaction must be a single valid emoji." in str(response.json())
|
||||
|
||||
|
||||
def test_create_reaction_multiple_emojis():
|
||||
"""Users should not be able to submit multiple emojis as a single reaction."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.RoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "🐛🐛"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Reaction must be a single valid emoji." in str(response.json())
|
||||
|
||||
|
||||
# Delete reaction
|
||||
|
||||
@@ -55,6 +55,31 @@ def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_url_with_surrounding_whitespace(mock_getaddrinfo):
|
||||
"""
|
||||
URLs with leading or trailing whitespace must still be proxied successfully,
|
||||
otherwise images whose `src` has stray whitespace are missing from the PDF export.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url= {url_to_fetch} "
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "image/png"
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
def test_api_docs_cors_proxy_without_url_query_string():
|
||||
"""Test the CORS proxy API for documents without a URL query string."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
@@ -6,7 +6,6 @@ from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -37,7 +36,7 @@ def test_api_documents_media_auth_unkown_document():
|
||||
assert models.Document.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_public():
|
||||
def test_api_documents_media_auth_anonymous_public(settings):
|
||||
"""Anonymous users should be able to retrieve attachments linked to a public document"""
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
@@ -139,7 +138,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
|
||||
assert "Authorization" not in response
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_attachments():
|
||||
def test_api_documents_media_auth_anonymous_attachments(settings):
|
||||
"""
|
||||
Declaring a media key as original attachment on a document to which
|
||||
a user has access should give them access to the attachment file
|
||||
@@ -202,7 +201,9 @@ def test_api_documents_media_auth_anonymous_attachments():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
||||
def test_api_documents_media_auth_authenticated_public_or_authenticated(
|
||||
reach, settings
|
||||
):
|
||||
"""
|
||||
Authenticated users who are not related to a document should be able to retrieve
|
||||
attachments related to a document with public or authenticated link reach.
|
||||
@@ -284,7 +285,7 @@ def test_api_documents_media_auth_authenticated_restricted():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
def test_api_documents_media_auth_related(via, mock_user_teams, settings):
|
||||
"""
|
||||
Users who have a specific access to a document, whatever the role, should be able to
|
||||
retrieve related attachments.
|
||||
@@ -368,7 +369,7 @@ def test_api_documents_media_auth_not_ready_status():
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_missing_status_metadata():
|
||||
def test_api_documents_media_auth_missing_status_metadata(settings):
|
||||
"""Attachments without status metadata should be considered as ready"""
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
@@ -412,3 +413,51 @@ def test_api_documents_media_auth_missing_status_metadata():
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_public_custom_origin_header(settings):
|
||||
"""Changing the setting MEDIA_AUTH_ORIGINAL_URL_HEADER to match other header should work"""
|
||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER = "HTTP_X_FORWARDED_URI"
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
key = f"{document_id!s}/attachments/{filename:s}"
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
now = timezone.now()
|
||||
with freeze_time(now):
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
@@ -438,3 +438,92 @@ def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
|
||||
# Verify that the document has not moved
|
||||
document.refresh_from_db()
|
||||
assert document.is_root() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("position", enums.MoveNodePositionChoices.values)
|
||||
def test_api_documents_move_to_descendant(position):
|
||||
"""
|
||||
Moving a document to one of its descendants should return a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a hierarchy: parent -> child -> grandchild
|
||||
parent = factories.DocumentFactory(users=[(user, "owner")])
|
||||
child = factories.DocumentFactory(parent=parent, users=[(user, "owner")])
|
||||
grandchild = factories.DocumentFactory(parent=child, users=[(user, "owner")])
|
||||
|
||||
# Try moving parent to child (descendant)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{parent.id!s}/move/",
|
||||
data={"target_document_id": str(child.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Try moving parent to grandchild
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{parent.id!s}/move/",
|
||||
data={"target_document_id": str(grandchild.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Try moving child to grandchild (still descendant)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{child.id!s}/move/",
|
||||
data={"target_document_id": str(grandchild.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Ensure documents have not moved
|
||||
parent.refresh_from_db()
|
||||
child.refresh_from_db()
|
||||
grandchild.refresh_from_db()
|
||||
assert parent.is_root() is True
|
||||
assert child.is_child_of(parent) is True
|
||||
assert grandchild.is_child_of(child) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"position",
|
||||
[
|
||||
enums.MoveNodePositionChoices.FIRST_CHILD,
|
||||
enums.MoveNodePositionChoices.LAST_CHILD,
|
||||
],
|
||||
)
|
||||
def test_api_documents_move_to_self(position):
|
||||
"""
|
||||
Moving a document to itself should return a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
|
||||
# Try moving document to itself
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||
data={"target_document_id": str(document.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Ensure document has not moved
|
||||
document.refresh_from_db()
|
||||
assert document.is_root() is True
|
||||
|
||||
@@ -124,3 +124,22 @@ def test_api_documents_restore_authenticated_owner_expired():
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_documents_restore_authenticated_owner_not_deleted():
|
||||
"""Restoring a document that is not deleted should return a 400 error."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "This document is not deleted."}
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at is None
|
||||
|
||||
@@ -68,8 +68,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -91,10 +91,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role
|
||||
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||
else document.link_role,
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -116,8 +114,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -180,7 +178,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": document.ancestors_link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -203,7 +201,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -249,7 +247,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -327,7 +325,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -350,7 +348,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -373,7 +371,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -437,7 +435,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -460,7 +458,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -483,7 +481,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
|
||||
@@ -130,6 +130,12 @@ class Base(Configuration):
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
)
|
||||
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
|
||||
default="HTTP_X_ORIGINAL_URL",
|
||||
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
|
||||
@@ -46,6 +46,7 @@ dependencies = [
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.1.26",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"emoji==2.15.0",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==25.1.0",
|
||||
"jsonschema==4.26.0",
|
||||
|
||||
@@ -501,7 +501,7 @@ test.describe('Doc Header', () => {
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
await mockedDocument(page, {
|
||||
const uuid = await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means owner
|
||||
link_configuration: true,
|
||||
@@ -534,9 +534,7 @@ test.describe('Doc Header', () => {
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
const origin = await page.evaluate(() => window.location.origin);
|
||||
expect(clipboardContent.trim()).toMatch(
|
||||
`${origin}/docs/mocked-document-id/`,
|
||||
);
|
||||
expect(clipboardContent.trim()).toMatch(`${origin}/docs/${uuid}/`);
|
||||
});
|
||||
|
||||
test('it pins a document', async ({ page, browserName }) => {
|
||||
|
||||
@@ -137,13 +137,10 @@ export const createDoc = async (
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/docs/**', {
|
||||
timeout: 10000,
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
const input = page.getByLabel('Document title');
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(input).toHaveText('');
|
||||
|
||||
await input.fill(randomDocs[i]);
|
||||
@@ -251,6 +248,7 @@ export const waitForResponseCreateDoc = (page: Page) => {
|
||||
|
||||
export const mockedDocument = async (page: Page, data: object) => {
|
||||
// document/[ID]/ or document/[ID]/tree/ routes
|
||||
const uuid = crypto.randomUUID();
|
||||
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && !request.url().includes('page=')) {
|
||||
@@ -259,7 +257,7 @@ export const mockedDocument = async (page: Page, data: object) => {
|
||||
};
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: 'mocked-document-id',
|
||||
id: uuid,
|
||||
title: 'Mocked document',
|
||||
path: '000000',
|
||||
abilities: {
|
||||
@@ -304,6 +302,8 @@ export const mockedDocument = async (page: Page, data: object) => {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
return uuid;
|
||||
};
|
||||
|
||||
export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.3.12",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"uuid": "14.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"yjs": "*",
|
||||
"zod": "4.3.6",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ImperativePanelHandle,
|
||||
Panel,
|
||||
@@ -15,6 +16,27 @@ const pxToPercent = (px: number) => {
|
||||
return (px / window.innerWidth) * 100;
|
||||
};
|
||||
|
||||
const RESIZE_HANDLE_ID = 'left-panel-resize-handle';
|
||||
|
||||
const getValueLabel = (
|
||||
current: number,
|
||||
min: number,
|
||||
max: number,
|
||||
t: (key: string) => string,
|
||||
): string => {
|
||||
if (max <= min) {
|
||||
return t('Sidebar width: medium');
|
||||
}
|
||||
const ratio = (current - min) / (max - min);
|
||||
if (ratio < 1 / 3) {
|
||||
return t('Sidebar width: narrow');
|
||||
}
|
||||
if (ratio < 2 / 3) {
|
||||
return t('Sidebar width: medium');
|
||||
}
|
||||
return t('Sidebar width: wide');
|
||||
};
|
||||
|
||||
type ResizableLeftPanelProps = {
|
||||
leftPanel: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
@@ -28,6 +50,7 @@ export const ResizableLeftPanel = ({
|
||||
minPanelSizePx = 300,
|
||||
maxPanelSizePx = 450,
|
||||
}: ResizableLeftPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { isPanelOpen } = useLeftPanelStore();
|
||||
const ref = useRef<ImperativePanelHandle>(null);
|
||||
@@ -96,6 +119,24 @@ export const ResizableLeftPanel = ({
|
||||
};
|
||||
}, [isDesktop]);
|
||||
|
||||
/**
|
||||
* Workaround: NVDA does not enter focus mode for role="separator"
|
||||
* (https://github.com/nvaccess/nvda/issues/11403), so arrow keys are
|
||||
* intercepted by browse-mode navigation and never reach the handle.
|
||||
* Changing the role to "slider" makes NVDA reliably switch to focus
|
||||
* mode, restoring progressive keyboard resize with arrow keys.
|
||||
*
|
||||
* Note: PanelResizeHandle does not expose a ref (no RefAttributes in its
|
||||
* type definition), so we use id + getElementById as the only viable option.
|
||||
* Only role needs to be overridden here; aria-* props are passed directly.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isPanelOpen) {
|
||||
return;
|
||||
}
|
||||
document.getElementById(RESIZE_HANDLE_ID)?.setAttribute('role', 'slider');
|
||||
}, [isPanelOpen]);
|
||||
|
||||
const handleResize = (sizePercent: number) => {
|
||||
const widthPx = (sizePercent / 100) * window.innerWidth;
|
||||
savedWidthPxRef.current = widthPx;
|
||||
@@ -103,7 +144,7 @@ export const ResizableLeftPanel = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelGroup direction="horizontal">
|
||||
<PanelGroup direction="horizontal" keyboardResizeBy={1}>
|
||||
<Panel
|
||||
ref={ref}
|
||||
className="--docs--resizable-left-panel"
|
||||
@@ -132,6 +173,18 @@ export const ResizableLeftPanel = ({
|
||||
</Panel>
|
||||
{isPanelOpen && (
|
||||
<PanelResizeHandle
|
||||
id={RESIZE_HANDLE_ID}
|
||||
aria-label={t('Resize sidebar')}
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={Math.round(minPanelSizePercent)}
|
||||
aria-valuemax={Math.round(maxPanelSizePercent)}
|
||||
aria-valuenow={Math.round(panelSizePercent)}
|
||||
aria-valuetext={getValueLabel(
|
||||
panelSizePercent,
|
||||
minPanelSizePercent,
|
||||
maxPanelSizePercent,
|
||||
t,
|
||||
)}
|
||||
style={{
|
||||
borderRightWidth: '1px',
|
||||
borderRightStyle: 'solid',
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
"express-ws": "5.0.2",
|
||||
"uuid": "13.0.0",
|
||||
"uuid": "14.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"yjs": "*"
|
||||
},
|
||||
|
||||
@@ -17089,10 +17089,10 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
uuid@13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8"
|
||||
integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==
|
||||
uuid@14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
|
||||
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
|
||||
|
||||
uuid@^8.3.2:
|
||||
version "8.3.2"
|
||||
|
||||
Reference in New Issue
Block a user