Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c89909bd1a | |||
| 9991820cb1 | |||
| 2801ece358 | |||
| 0b37996899 | |||
| 0867ccef1a | |||
| b3ae6e1a30 | |||
| 1df6242927 | |||
| 35fba02085 | |||
| 0e5c9ed834 | |||
| 4e54a53072 | |||
| 4f8aea7b80 | |||
| 1172fbe0b5 | |||
| 7cf144e0de | |||
| 54c15c541e | |||
| 8472e661f5 | |||
| 1d819d8fa2 | |||
| 5020bc1c1a | |||
| 4cd72ffa4f | |||
| c1998a9b24 | |||
| 0fca6db79c |
+1
-1
@@ -34,4 +34,4 @@ db.sqlite3
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
.next
|
||||
**/.next
|
||||
|
||||
+38
-5
@@ -6,6 +6,42 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- 💫(frontend) fix the help button to the bottom in tree #2073
|
||||
|
||||
## [v4.8.2] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add resource server api #1923
|
||||
- ✨(frontend) activate Find search #1834
|
||||
- ✨ handle searching on subdocuments #1834
|
||||
- ✨(backend) add search feature flags #1897
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) ensure doc title is h1 for accessibility #2006
|
||||
- ♿️(frontend) add nb accesses in share button aria-label #2017
|
||||
- ✨(backend) improve fallback logic on search endpoint #1834
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix image resizing when caption #2045
|
||||
- 🙈(docker) add \*\*/.next to .dockerignore #2034
|
||||
- ♿️(frontend) fix share modal heading hierarchy #2007
|
||||
- ♿️(frontend) fix Copy link toast accessibility for screen readers #2029
|
||||
- ♿️(frontend) fix modal aria-label and name #2014
|
||||
- ♿️(frontend) fix language dropdown ARIA for screen readers #2020
|
||||
- ♿️(frontend) fix waffle aria-label spacing for new-window links #2030
|
||||
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
|
||||
- 🐛(backend) duplicate a document as last-sibling #2084
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
|
||||
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
|
||||
|
||||
## [v4.8.1] - 2026-03-17
|
||||
|
||||
### Added
|
||||
@@ -107,16 +143,12 @@ and this project adheres to
|
||||
- ✨(frontend) Add stat for Crisp #1824
|
||||
- ✨(auth) add silent login #1690
|
||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||
- ✨(frontend) activate Find search #1834
|
||||
- ✨ handle searching on subdocuments #1834
|
||||
- ✨(backend) add search feature flags #1897
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
||||
- ✨(backend) add field for button label in email template #1817
|
||||
- ✨(backend) improve fallback logic on search endpoint #1834
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1121,7 +1153,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.1...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.2...main
|
||||
[v4.8.2]: https://github.com/suitenumerique/docs/releases/v4.8.2
|
||||
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
|
||||
[v4.8.0]: https://github.com/suitenumerique/docs/releases/v4.8.0
|
||||
[v4.7.0]: https://github.com/suitenumerique/docs/releases/v4.7.0
|
||||
|
||||
@@ -162,6 +162,10 @@ endif
|
||||
@echo ""
|
||||
.PHONY: post-beautiful-bootstrap
|
||||
|
||||
create-docker-network: ## create the docker network if it doesn't exist
|
||||
@docker network create lasuite-network || true
|
||||
.PHONY: create-docker-network
|
||||
|
||||
bootstrap: ## Prepare the project for local development
|
||||
bootstrap: \
|
||||
pre-beautiful-bootstrap \
|
||||
@@ -219,6 +223,7 @@ logs: ## display app-dev logs (follow mode)
|
||||
.PHONY: logs
|
||||
|
||||
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
|
||||
|
||||
@@ -47,6 +47,10 @@ server {
|
||||
try_files $uri @proxy_to_docs_backend;
|
||||
}
|
||||
|
||||
location /external_api {
|
||||
try_files $uri @proxy_to_docs_backend;
|
||||
}
|
||||
|
||||
location /static {
|
||||
try_files $uri @proxy_to_docs_backend;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Use Docs as a Resource Server
|
||||
|
||||
Docs implements resource server, so it means it can be used from an external app to perform some operation using the dedicated API.
|
||||
|
||||
> **Note:** This feature might be subject to future evolutions. The API endpoints, configuration options, and behavior may change in future versions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In order to activate the resource server on Docs you need to setup the following environment variables
|
||||
|
||||
```python
|
||||
OIDC_RESOURCE_SERVER_ENABLED=True
|
||||
OIDC_OP_URL=
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=
|
||||
OIDC_RS_CLIENT_ID=
|
||||
OIDC_RS_CLIENT_SECRET=
|
||||
OIDC_RS_AUDIENCE_CLAIM=
|
||||
OIDC_RS_ALLOWED_AUDIENCES=
|
||||
```
|
||||
|
||||
It implements the resource server using `django-lasuite`, see the [documentation](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-resource-server-backend.md)
|
||||
|
||||
## Customise allowed routes
|
||||
|
||||
Configure the `EXTERNAL_API` setting to control which routes and actions are available in the external API. Set it via the `EXTERNAL_API` environment variable (as JSON) or in Django settings.
|
||||
|
||||
Default configuration:
|
||||
|
||||
```python
|
||||
EXTERNAL_API = {
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "create", "children"],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": False,
|
||||
"actions": [],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": False,
|
||||
"actions": [],
|
||||
},
|
||||
"users": {
|
||||
"enabled": True,
|
||||
"actions": ["get_me"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `documents`: Controls `/external_api/v1.0/documents/`. Available actions: `list`, `retrieve`, `create`, `update`, `destroy`, `trashbin`, `children`, `restore`, `move`,`versions_list`, `versions_detail`, `favorite_detail`,`link_configuration`, `attachment_upload`, `media_auth`, `ai_transform`, `ai_translate`, `ai_proxy`. Always allowed actions: `favorite_list`, `duplicate`.
|
||||
- `document_access`: `/external_api/v1.0/documents/{id}/accesses/`. Available actions: `list`, `retrieve`, `create`, `update`, `partial_update`, `destroy`
|
||||
- `document_invitation`: Controls `/external_api/v1.0/documents/{id}/invitations/`. Available actions: `list`, `retrieve`, `create`, `partial_update`, `destroy`
|
||||
- `users`: Controls `/external_api/v1.0/documents/`. Available actions: `get_me`.
|
||||
|
||||
Each endpoint has `enabled` (boolean) and `actions` (list of allowed actions). Only actions explicitly listed are accessible.
|
||||
|
||||
## Request Docs
|
||||
|
||||
In order to request Docs from an external resource provider, you need to implement the basic setup of `django-lasuite` [Using the OIDC Authentication Backend to request a resource server](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-call-to-resource-server.md)
|
||||
|
||||
Then you can requests some routes that are available at `/external_api/v1.0/*`, here are some examples of what you can do.
|
||||
|
||||
### Create a document
|
||||
|
||||
Here is an example of a view that creates a document from a markdown file at the root level in Docs.
|
||||
|
||||
```python
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def create_document_from_markdown(self, request):
|
||||
"""
|
||||
Create a new document from a Markdown file at root level.
|
||||
"""
|
||||
|
||||
# Get the access token from the session
|
||||
access_token = request.session.get('oidc_access_token')
|
||||
|
||||
# Create a new document from a file
|
||||
file_content = b"# Test Document\n\nThis is a test."
|
||||
file = BytesIO(file_content)
|
||||
file.name = "readme.md"
|
||||
|
||||
response = requests.post(
|
||||
f"{settings.DOCS_API}/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return {"id": data["id"]}
|
||||
```
|
||||
|
||||
### Get user information
|
||||
|
||||
The same way, you can use the /me endpoint to get user information.
|
||||
|
||||
```python
|
||||
response = requests.get(
|
||||
"{settings.DOCS_API}/users/me/",
|
||||
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
|
||||
)
|
||||
```
|
||||
@@ -51,6 +51,15 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Resource Server Backend
|
||||
OIDC_OP_URL=http://localhost:8083/realms/docs
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = http://nginx:8083/realms/docs/protocol/openid-connect/token/introspect
|
||||
OIDC_RESOURCE_SERVER_ENABLED=False
|
||||
OIDC_RS_CLIENT_ID=docs
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_AUDIENCE_CLAIM="client_id" # The claim used to identify the audience
|
||||
OIDC_RS_ALLOWED_AUDIENCES=""
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
OIDC_STORE_ACCESS_TOKEN=True
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
||||
|
||||
@@ -457,36 +457,45 @@ class DocumentViewSet(
|
||||
|
||||
### Additional Actions:
|
||||
1. **Trashbin**: List soft deleted documents for a document owner
|
||||
Example: GET /documents/{id}/trashbin/
|
||||
Example: GET /documents/trashbin/
|
||||
|
||||
2. **Children**: List or create child documents.
|
||||
2. **Restore**: Restore a soft deleted document.
|
||||
Example: POST /documents/{id}/restore/
|
||||
|
||||
3. **Move**: Move a document to another parent document.
|
||||
Example: POST /documents/{id}/move/
|
||||
|
||||
4. **Duplicate**: Duplicate a document.
|
||||
Example: POST /documents/{id}/duplicate/
|
||||
|
||||
5. **Children**: List or create child documents.
|
||||
Example: GET, POST /documents/{id}/children/
|
||||
|
||||
3. **Versions List**: Retrieve version history of a document.
|
||||
6. **Versions List**: Retrieve version history of a document.
|
||||
Example: GET /documents/{id}/versions/
|
||||
|
||||
4. **Version Detail**: Get or delete a specific document version.
|
||||
7. **Version Detail**: Get or delete a specific document version.
|
||||
Example: GET, DELETE /documents/{id}/versions/{version_id}/
|
||||
|
||||
5. **Favorite**: Get list of favorite documents for a user. Mark or unmark
|
||||
8. **Favorite**: Get list of favorite documents for a user. Mark or unmark
|
||||
a document as favorite.
|
||||
Examples:
|
||||
- GET /documents/favorite/
|
||||
- GET /documents/favorite_list/
|
||||
- POST, DELETE /documents/{id}/favorite/
|
||||
|
||||
6. **Create for Owner**: Create a document via server-to-server on behalf of a user.
|
||||
9. **Create for Owner**: Create a document via server-to-server on behalf of a user.
|
||||
Example: POST /documents/create-for-owner/
|
||||
|
||||
7. **Link Configuration**: Update document link configuration.
|
||||
10. **Link Configuration**: Update document link configuration.
|
||||
Example: PUT /documents/{id}/link-configuration/
|
||||
|
||||
8. **Attachment Upload**: Upload a file attachment for the document.
|
||||
11. **Attachment Upload**: Upload a file attachment for the document.
|
||||
Example: POST /documents/{id}/attachment-upload/
|
||||
|
||||
9. **Media Auth**: Authorize access to document media.
|
||||
12. **Media Auth**: Authorize access to document media.
|
||||
Example: GET /documents/media-auth/
|
||||
|
||||
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
|
||||
13. **AI Transform**: Apply a transformation action on a piece of text with AI.
|
||||
Example: POST /documents/{id}/ai-transform/
|
||||
Expected data:
|
||||
- text (str): The input text.
|
||||
@@ -494,7 +503,7 @@ class DocumentViewSet(
|
||||
Returns: JSON response with the processed text.
|
||||
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
|
||||
|
||||
11. **AI Translate**: Translate a piece of text with AI.
|
||||
14. **AI Translate**: Translate a piece of text with AI.
|
||||
Example: POST /documents/{id}/ai-translate/
|
||||
Expected data:
|
||||
- text (str): The input text.
|
||||
@@ -502,7 +511,7 @@ class DocumentViewSet(
|
||||
Returns: JSON response with the translated text.
|
||||
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
|
||||
|
||||
12. **AI Proxy**: Proxy an AI request to an external AI service.
|
||||
15. **AI Proxy**: Proxy an AI request to an external AI service.
|
||||
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
|
||||
|
||||
### Ordering: created_at, updated_at, is_favorite, title
|
||||
@@ -1352,7 +1361,7 @@ class DocumentViewSet(
|
||||
)
|
||||
else:
|
||||
duplicated_document = document_to_duplicate.add_sibling(
|
||||
"right",
|
||||
"last-sibling",
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
@@ -1435,7 +1444,6 @@ class DocumentViewSet(
|
||||
# fallback on title search if the indexer is not reached
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
|
||||
def _get_search_type(self) -> SearchType:
|
||||
"""
|
||||
Returns the search type to use for the search endpoint based on feature flags.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Resource Server Permissions for the Docs app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class ResourceServerClientPermission(permissions.BasePermission):
|
||||
"""
|
||||
Permission class for resource server views.
|
||||
This provides a way to open the resource server views to a limited set of
|
||||
Service Providers.
|
||||
Note: we might add a more complex permission system in the future, based on
|
||||
the Service Provider ID and the requested scopes.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check if the user is authenticated and the token introspection
|
||||
provides an authorized Service Provider.
|
||||
"""
|
||||
if not isinstance(
|
||||
request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# Not a resource server request
|
||||
return False
|
||||
|
||||
# Check if the user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
if (
|
||||
hasattr(view, "resource_server_actions")
|
||||
and view.action not in view.resource_server_actions
|
||||
):
|
||||
return False
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
return (
|
||||
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Resource Server Viewsets for the Docs app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
from core.api.permissions import (
|
||||
CanCreateInvitationPermission,
|
||||
DocumentPermission,
|
||||
IsSelf,
|
||||
ResourceAccessPermission,
|
||||
)
|
||||
from core.api.viewsets import (
|
||||
DocumentAccessViewSet,
|
||||
DocumentViewSet,
|
||||
InvitationViewset,
|
||||
UserViewSet,
|
||||
)
|
||||
from core.external_api.permissions import ResourceServerClientPermission
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class ResourceServerRestrictionMixin:
|
||||
"""
|
||||
Mixin for Resource Server Viewsets to provide shortcut to get
|
||||
configured actions for a given resource.
|
||||
"""
|
||||
|
||||
def _get_resource_server_actions(self, resource_name):
|
||||
"""Get resource_server_actions from settings."""
|
||||
external_api_config = settings.EXTERNAL_API.get(resource_name, {})
|
||||
return list(external_api_config.get("actions", []))
|
||||
|
||||
|
||||
class ResourceServerDocumentViewSet(ResourceServerRestrictionMixin, DocumentViewSet):
|
||||
"""Resource Server Viewset for Documents."""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
permission_classes = [ResourceServerClientPermission & DocumentPermission] # type: ignore
|
||||
|
||||
@property
|
||||
def resource_server_actions(self):
|
||||
"""Build resource_server_actions from settings."""
|
||||
return self._get_resource_server_actions("documents")
|
||||
|
||||
|
||||
class ResourceServerDocumentAccessViewSet(
|
||||
ResourceServerRestrictionMixin, DocumentAccessViewSet
|
||||
):
|
||||
"""Resource Server Viewset for DocumentAccess."""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
permission_classes = [ResourceServerClientPermission & ResourceAccessPermission] # type: ignore
|
||||
|
||||
@property
|
||||
def resource_server_actions(self):
|
||||
"""Get resource_server_actions from settings."""
|
||||
return self._get_resource_server_actions("document_access")
|
||||
|
||||
|
||||
class ResourceServerInvitationViewSet(
|
||||
ResourceServerRestrictionMixin, InvitationViewset
|
||||
):
|
||||
"""Resource Server Viewset for Invitations."""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
permission_classes = [
|
||||
ResourceServerClientPermission & CanCreateInvitationPermission
|
||||
]
|
||||
|
||||
@property
|
||||
def resource_server_actions(self):
|
||||
"""Get resource_server_actions from settings."""
|
||||
return self._get_resource_server_actions("document_invitation")
|
||||
|
||||
|
||||
class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
|
||||
"""Resource Server Viewset for User."""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
permission_classes = [ResourceServerClientPermission & IsSelf] # type: ignore
|
||||
|
||||
@property
|
||||
def resource_server_actions(self):
|
||||
"""Get resource_server_actions from settings."""
|
||||
return self._get_resource_server_actions("users")
|
||||
@@ -285,8 +285,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
return
|
||||
|
||||
sandbox_document = template_document.add_sibling(
|
||||
"right",
|
||||
sandbox_document = Document.add_root(
|
||||
title=template_document.title,
|
||||
content=template_document.content,
|
||||
attachments=template_document.attachments,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Fixtures for tests in the impress core application"""
|
||||
|
||||
import base64
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import factories
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
@@ -49,3 +54,92 @@ def indexer_settings_fixture(settings):
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
|
||||
def resource_server_backend_setup(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RESOURCE_SERVER_ENABLED = True
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_RS_SCOPES = ["openid", "groups"]
|
||||
settings.OIDC_RS_ALLOWED_AUDIENCES = ["some_service_provider"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_server_backend_conf(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
resource_server_backend_setup(settings)
|
||||
reload_urls()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_server_backend(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
Including a mocked introspection endpoint.
|
||||
"""
|
||||
resource_server_backend_setup(settings)
|
||||
reload_urls()
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
yield rsps
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_specific_sub():
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
user = factories.UserFactory(sub="very-specific-sub", full_name="External User")
|
||||
|
||||
yield user
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_token():
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
return build_authorization_bearer("some_token")
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_api_documents_duplicate_success(index):
|
||||
image_refs[0][0]
|
||||
] # Only the first image key
|
||||
assert duplicated_document.get_parent() == document.get_parent()
|
||||
assert duplicated_document.path == document.get_next_sibling().path
|
||||
assert duplicated_document.path == document.get_last_sibling().path
|
||||
|
||||
# Check that accesses were not duplicated.
|
||||
# The user who did the duplicate is forced as owner
|
||||
@@ -180,6 +180,7 @@ def test_api_documents_duplicate_with_accesses_admin(role):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
documents_before = factories.DocumentFactory.create_batch(20)
|
||||
document = factories.DocumentFactory(
|
||||
users=[(user, role)],
|
||||
title="document with accesses",
|
||||
@@ -187,6 +188,12 @@ def test_api_documents_duplicate_with_accesses_admin(role):
|
||||
user_access = factories.UserDocumentAccessFactory(document=document)
|
||||
team_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
|
||||
documents_after = factories.DocumentFactory.create_batch(20)
|
||||
|
||||
all_documents = documents_before + [document] + documents_after
|
||||
|
||||
paths = {document.pk: document.path for document in all_documents}
|
||||
|
||||
# Duplicate the document via the API endpoint requesting to duplicate accesses
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/duplicate/",
|
||||
@@ -212,6 +219,10 @@ def test_api_documents_duplicate_with_accesses_admin(role):
|
||||
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
|
||||
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
|
||||
|
||||
for document in all_documents:
|
||||
document.refresh_from_db()
|
||||
assert document.path == paths[document.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||
def test_api_documents_duplicate_with_accesses_non_admin(role):
|
||||
|
||||
@@ -48,6 +48,7 @@ def test_api_documents_search_success( # noqa : PLR0913
|
||||
expected_search_type,
|
||||
expected_search_with_indexer_called,
|
||||
expected_title_search_called,
|
||||
indexer_settings,
|
||||
):
|
||||
"""
|
||||
Test that the API endpoint for searching documents returns a successful response
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
"""
|
||||
Tests for the Resource Server API for documents.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services import mime_types
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_retrieve_anonymous_public_standalone():
|
||||
"""
|
||||
Anonymous users SHOULD NOT be allowed to retrieve a document from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
response = APIClient().get(f"/external_api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_external_api_documents_list_connected_not_resource_server():
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list documents if resource server is not enabled.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_external_api_documents_list_connected_resource_server(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""Connected users should be allowed to list documents from a resource server."""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role="reader"
|
||||
)
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_external_api_documents_list_connected_resource_server_with_invalid_token(
|
||||
user_token, resource_server_backend
|
||||
):
|
||||
"""A user with an invalid sub SHOULD NOT be allowed to retrieve documents
|
||||
from a resource server."""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_external_api_documents_retrieve_connected_resource_server_with_wrong_abilities(
|
||||
user_token, user_specific_sub, resource_server_backend
|
||||
):
|
||||
"""
|
||||
A user with wrong abilities SHOULD NOT be allowed to retrieve a document from
|
||||
a resource server.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_documents_retrieve_connected_resource_server_using_access_token(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
A user with an access token SHOULD be allowed to retrieve a document from
|
||||
a resource server.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.LinkRoleChoices.READER
|
||||
)
|
||||
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_external_api_documents_create_root_success(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users with an access token should be able to create a root document through the resource
|
||||
server and should automatically be declared as the owner of the newly created document.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.post(
|
||||
"/external_api/v1.0/documents/",
|
||||
{
|
||||
"title": "Test Root Document",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
document = models.Document.objects.get(id=data["id"])
|
||||
|
||||
assert document.title == "Test Root Document"
|
||||
assert document.creator == user_specific_sub
|
||||
assert document.accesses.filter(role="owner", user=user_specific_sub).exists()
|
||||
|
||||
|
||||
def test_external_api_documents_create_subdocument_owner_success(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users with an access token SHOULD BE able to create a sub-document through the resource
|
||||
server when they have OWNER permissions on the parent document.
|
||||
The creator is set to the authenticated user, and permissions are inherited
|
||||
from the parent document.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
# Create a parent document first
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=parent_document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{parent_document.id}/children/",
|
||||
{
|
||||
"title": "Test Sub Document",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
document = models.Document.objects.get(id=data["id"])
|
||||
|
||||
assert document.title == "Test Sub Document"
|
||||
assert document.creator == user_specific_sub
|
||||
assert document.get_parent() == parent_document
|
||||
# Child documents inherit permissions from parent, no direct access needed
|
||||
assert not document.accesses.exists()
|
||||
|
||||
|
||||
def test_external_api_documents_create_subdocument_editor_success(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users with an access token SHOULD BE able to create a sub-document through the resource
|
||||
server when they have EDITOR permissions on the parent document.
|
||||
Permissions are inherited from the parent document.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
# Create a parent document first
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=parent_document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.EDITOR,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{parent_document.id}/children/",
|
||||
{
|
||||
"title": "Test Sub Document",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
document = models.Document.objects.get(id=data["id"])
|
||||
|
||||
assert document.title == "Test Sub Document"
|
||||
assert document.creator == user_specific_sub
|
||||
assert document.get_parent() == parent_document
|
||||
# Child documents inherit permissions from parent, no direct access needed
|
||||
assert not document.accesses.exists()
|
||||
|
||||
|
||||
def test_external_api_documents_create_subdocument_reader_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users with an access token SHOULD NOT be able to create a sub-document through the resource
|
||||
server when they have READER permissions on the parent document.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
# Create a parent document first
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=parent_document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.READER,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{parent_document.id}/children/",
|
||||
{
|
||||
"title": "Test Sub Document",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_external_api_documents_create_with_markdown_file_success(
|
||||
mock_convert, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users with an access token should be able to create documents through the resource
|
||||
server by uploading a Markdown file and should automatically be declared as the owner
|
||||
of the newly created document.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake Markdown file
|
||||
file_content = b"# Test Document\n\nThis is a test."
|
||||
file = BytesIO(file_content)
|
||||
file.name = "readme.md"
|
||||
|
||||
response = client.post(
|
||||
"/external_api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
document = models.Document.objects.get(id=data["id"])
|
||||
|
||||
assert document.title == "readme.md"
|
||||
assert document.content == converted_yjs
|
||||
assert document.accesses.filter(role="owner", user=user_specific_sub).exists()
|
||||
|
||||
# Verify the converter was called correctly
|
||||
mock_convert.assert_called_once_with(
|
||||
file_content,
|
||||
content_type=mime_types.MARKDOWN,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
|
||||
|
||||
def test_external_api_documents_list_with_multiple_roles(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
List all documents accessible to a user with different roles and verify
|
||||
that associated permissions are correctly returned in the response.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
# Create documents with different roles for the user
|
||||
owner_document = factories.DocumentFactory(
|
||||
title="Owner Document",
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=owner_document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
editor_document = factories.DocumentFactory(
|
||||
title="Editor Document",
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=editor_document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.EDITOR,
|
||||
)
|
||||
|
||||
reader_document = factories.DocumentFactory(
|
||||
title="Reader Document",
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=reader_document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.READER,
|
||||
)
|
||||
|
||||
# Create a document the user should NOT have access to
|
||||
other_document = factories.DocumentFactory(
|
||||
title="Other Document",
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
other_user = factories.UserFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=other_document,
|
||||
user=other_user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify the response contains results
|
||||
assert "results" in data
|
||||
results = data["results"]
|
||||
|
||||
# Verify user can see exactly 3 documents (owner, editor, reader)
|
||||
result_ids = {result["id"] for result in results}
|
||||
assert len(results) == 3
|
||||
assert str(owner_document.id) in result_ids
|
||||
assert str(editor_document.id) in result_ids
|
||||
assert str(reader_document.id) in result_ids
|
||||
assert str(other_document.id) not in result_ids
|
||||
|
||||
# Verify each document has correct user_role field indicating permission level
|
||||
for result in results:
|
||||
if result["id"] == str(owner_document.id):
|
||||
assert result["title"] == "Owner Document"
|
||||
assert result["user_role"] == models.RoleChoices.OWNER
|
||||
elif result["id"] == str(editor_document.id):
|
||||
assert result["title"] == "Editor Document"
|
||||
assert result["user_role"] == models.RoleChoices.EDITOR
|
||||
elif result["id"] == str(reader_document.id):
|
||||
assert result["title"] == "Reader Document"
|
||||
assert result["user_role"] == models.RoleChoices.READER
|
||||
|
||||
|
||||
def test_external_api_documents_duplicate_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users CAN DUPLICATE a document from a resource server
|
||||
when they have the required permissions on the document,
|
||||
as this action bypasses the permission checks.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/duplicate/",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
# NOT allowed actions on resource server.
|
||||
|
||||
|
||||
def test_external_api_documents_put_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to PUT a document from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/", {"title": "new title"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_document_delete_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to delete a document from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_documents_move_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to MOVE a document from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
new_parent = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=new_parent,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/move/",
|
||||
{"target_document_id": new_parent.id},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_documents_restore_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to restore a document from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/restore/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_external_api_documents_trashbin_not_allowed(
|
||||
role, reach, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list documents from the trashbin,
|
||||
regardless of the document link reach and user role, from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=reach,
|
||||
creator=user_specific_sub,
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=role,
|
||||
)
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/trashbin/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_documents_create_for_owner_not_allowed():
|
||||
"""
|
||||
Authenticated users SHOULD NOT be allowed to call create documents
|
||||
on behalf of other users.
|
||||
This API endpoint is reserved for server-to-server calls.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/external_api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not models.Document.objects.exists()
|
||||
|
||||
|
||||
# Test overrides
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "children", "trashbin"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_trashbin_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to list soft deleted documents from a resource server
|
||||
when the trashbin action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
document.soft_delete()
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/trashbin/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "children", "destroy"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_delete_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to delete a document from a resource server
|
||||
when the delete action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
# Verify the document is soft deleted
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"update",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_update_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to update a document from a resource server
|
||||
when the update action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
original_title = document.title
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/", {"title": "new title"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Verify the document is updated
|
||||
document.refresh_from_db()
|
||||
assert document.title == "new title"
|
||||
assert document.title != original_title
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "children", "move"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_move_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to move a document from a resource server
|
||||
when the move action is enabled in EXTERNAL_API settings and they
|
||||
have the required permissions on the document and the target location.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
parent = factories.DocumentFactory(
|
||||
users=[(user_specific_sub, "owner")], teams=[("lasuite", "owner")]
|
||||
)
|
||||
# A document with no owner
|
||||
document = factories.DocumentFactory(
|
||||
parent=parent, users=[(user_specific_sub, "reader")]
|
||||
)
|
||||
target = factories.DocumentFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/move/",
|
||||
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Document moved successfully."}
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "children", "restore"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_restore_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to restore a recently soft-deleted document
|
||||
from a resource server when the restore action is enabled in EXTERNAL_API
|
||||
settings and they have the required permissions on the document.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
now = timezone.now() - timedelta(days=15)
|
||||
document = factories.DocumentFactory(deleted_at=now)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role="owner"
|
||||
)
|
||||
|
||||
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/restore/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document has been successfully restored."}
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at is None
|
||||
@@ -0,0 +1,681 @@
|
||||
"""
|
||||
Tests for the Resource Server API for documents accesses.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
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
|
||||
from core.api import serializers
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_document_accesses_anonymous_public_standalone():
|
||||
"""
|
||||
Anonymous users SHOULD NOT be allowed to list document accesses
|
||||
from external API if resource server is not enabled.
|
||||
"""
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_external_api_document_accesses_list_connected_not_resource_server():
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list document accesses
|
||||
if resource server is not enabled.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_list_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list the accesses of
|
||||
a document from a resource server.
|
||||
"""
|
||||
reload_urls()
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/accesses/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_retrieve_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to retrieve a specific access of
|
||||
a document from a resource server.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_accesses_create_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to create an access for a document
|
||||
from a resource server.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{"user_id": other_user.id, "role": models.RoleChoices.READER},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_update_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to update an access for a
|
||||
document from a resource server through PUT.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=other_user, role=models.RoleChoices.READER
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
{"role": models.RoleChoices.EDITOR},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_partial_update_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to update an access
|
||||
for a document from a resource server through PATCH.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=other_user, role=models.RoleChoices.READER
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
{"role": models.RoleChoices.EDITOR},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_accesses_delete_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to delete an access for
|
||||
a document from a resource server.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Overrides
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_list_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to list the accesses of a document from a resource server
|
||||
when the list action is enabled in EXTERNAL_API document_access settings.
|
||||
"""
|
||||
|
||||
reload_urls()
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED, creator=user_specific_sub
|
||||
)
|
||||
user_access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
# Create additional accesses
|
||||
other_user = factories.UserFactory()
|
||||
other_access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=other_user, role=models.RoleChoices.READER
|
||||
)
|
||||
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/accesses/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
access_ids = [entry["id"] for entry in data]
|
||||
assert str(user_access.id) in access_ids
|
||||
assert str(other_access.id) in access_ids
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_retrieve_can_be_allowed(
|
||||
user_token,
|
||||
resource_server_backend,
|
||||
user_specific_sub,
|
||||
):
|
||||
"""
|
||||
A user who is related to a document SHOULD be allowed to retrieve the
|
||||
associated document user accesses.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["id"] == str(access.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "create"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_document_accesses_create_can_be_allowed(
|
||||
user_token,
|
||||
resource_server_backend,
|
||||
user_specific_sub,
|
||||
):
|
||||
"""
|
||||
A user who is related to a document SHOULD be allowed to create
|
||||
a user access for the document.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/",
|
||||
data={"user_id": other_user.id, "role": models.RoleChoices.READER},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert response.status_code == 201
|
||||
assert data["role"] == models.RoleChoices.READER
|
||||
assert str(data["user"]["id"]) == str(other_user.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "update"],
|
||||
},
|
||||
}
|
||||
)
|
||||
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
|
||||
a user access for the document through PUT.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
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
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, "role": models.RoleChoices.EDITOR}, # type: ignore
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["role"] == models.RoleChoices.EDITOR
|
||||
assert str(data["user"]["id"]) == str(other_user.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "partial_update"],
|
||||
},
|
||||
}
|
||||
)
|
||||
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
|
||||
a user access for the document through PATCH.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
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},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["role"] == models.RoleChoices.EDITOR
|
||||
assert str(data["user"]["id"]) == str(other_user.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "destroy"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_accesses_delete_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub, settings
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to delete an access for
|
||||
a document from a resource server when the destroy action is
|
||||
enabled in settings.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
other_user = factories.UserFactory()
|
||||
other_access = factories.UserDocumentAccessFactory(
|
||||
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}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Tests for the Resource Server API for document AI features.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.documents.test_api_documents_ai_proxy import ( # pylint: disable=unused-import
|
||||
ai_settings,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_ai_transform_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to access AI transform endpoints
|
||||
from a resource server by default.
|
||||
"""
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/ai-transform/",
|
||||
{"text": "hello", "action": "prompt"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_external_api_documents_ai_translate_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to access AI translate endpoints
|
||||
from a resource server by default.
|
||||
"""
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/ai-translate/",
|
||||
{"text": "hello", "language": "es"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_external_api_documents_ai_proxy_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to access AI proxy endpoints
|
||||
from a resource server by default.
|
||||
"""
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/ai-proxy/",
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
# Overrides
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"ai_transform",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_external_api_documents_ai_transform_can_be_allowed(
|
||||
mock_create, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users SHOULD be allowed to transform a document using AI when the
|
||||
corresponding action is enabled via EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/external_api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = client.post(url, {"text": "Hello", "action": "prompt"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
# pylint: disable=line-too-long
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"ai_translate",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_external_api_documents_ai_translate_can_be_allowed(
|
||||
mock_create, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users SHOULD be allowed to translate a document using AI when the
|
||||
corresponding action is enabled via EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/external_api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = client.post(url, {"text": "Hello", "language": "es-co"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"ai_proxy",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
def test_external_api_documents_ai_proxy_can_be_allowed(
|
||||
mock_stream, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users SHOULD be allowed to use the AI proxy endpoint when the
|
||||
corresponding action is enabled via EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED, creator=user_specific_sub
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
mock_stream.return_value = iter(["data: response\n"])
|
||||
|
||||
url = f"/external_api/v1.0/documents/{document.id!s}/ai-proxy/"
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "text/event-stream" # type: ignore
|
||||
mock_stream.assert_called_once()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Tests for the Resource Server API for document attachments.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_attachment_upload_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to upload attachments to a document
|
||||
from a resource server.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
pixel = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
|
||||
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
|
||||
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
file = SimpleUploadedFile(name="test.png", content=pixel, content_type="image/png")
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/attachment-upload/",
|
||||
{"file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"attachment_upload",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_attachment_upload_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to upload attachments to a document
|
||||
from a resource server when the attachment-upload action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
pixel = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
|
||||
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
|
||||
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
file = SimpleUploadedFile(name="test.png", content=pixel, content_type="image/png")
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/attachment-upload/",
|
||||
{"file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
|
||||
url_parsed = urlparse(response.json()["file"])
|
||||
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||
query = parse_qs(url_parsed.query)
|
||||
assert query["key"][0] is not None
|
||||
file_path = query["key"][0]
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1) # type: ignore
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Tests for the Resource Server API for document favorites.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_favorites_list_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to list their favorites
|
||||
from a resource server, as favorite_list() bypasses permissions.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.UserDocumentAccessFactory(
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.READER,
|
||||
document__favorited_by=[user_specific_sub],
|
||||
).document
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/favorite_list/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["results"][0]["id"] == str(document.id)
|
||||
|
||||
|
||||
def test_external_api_documents_favorite_add_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
By default the "favorite" action is not permitted on the external API.
|
||||
POST to the endpoint must return 403.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_documents_favorite_delete_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
By default the "favorite" action is not permitted on the external API.
|
||||
DELETE to the endpoint must return 403.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Overrides
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"favorite",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_favorite_add_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users SHOULD be allowed to POST to the favorite endpoint when the
|
||||
corresponding action is enabled via EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
|
||||
assert response.status_code == 201
|
||||
assert models.DocumentFavorite.objects.filter(
|
||||
document=document, user=user_specific_sub
|
||||
).exists()
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"favorite",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_favorite_delete_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Users SHOULD be allowed to DELETE from the favorite endpoint when the
|
||||
corresponding action is enabled via EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
|
||||
assert response.status_code == 204
|
||||
assert not models.DocumentFavorite.objects.filter(
|
||||
document=document, user=user_specific_sub
|
||||
).exists()
|
||||
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Tests for the Resource Server API for invitations.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_document_invitations_anonymous_public_standalone():
|
||||
"""
|
||||
Anonymous users SHOULD NOT be allowed to list invitations from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
invitation = factories.InvitationFactory()
|
||||
response = APIClient().get(
|
||||
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_external_api_document_invitations_list_connected_not_resource_server():
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list document invitations
|
||||
if resource server is not enabled.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
response = APIClient().get(
|
||||
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_list_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list document invitations
|
||||
by default.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
response = client.get(
|
||||
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_retrieve_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to retrieve a document invitation
|
||||
by default.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
document = invitation.document
|
||||
|
||||
response = client.get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_create_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to create a document invitation
|
||||
by default.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/",
|
||||
{"email": "invited@example.com", "role": models.RoleChoices.READER},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_partial_update_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to partially update a document invitation
|
||||
by default.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
invitation = factories.InvitationFactory(
|
||||
document=document, role=models.RoleChoices.READER
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
|
||||
{"role": models.RoleChoices.EDITOR},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_delete_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to delete a document invitation
|
||||
by default.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
|
||||
response = client.delete(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Overrides
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_list_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to list document invitations
|
||||
when the action is explicitly enabled.
|
||||
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/invitations/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["results"][0]["id"] == str(invitation.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_retrieve_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to retrieve a document invitation
|
||||
when the action is explicitly enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(invitation.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "create"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_create_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to create a document invitation
|
||||
when the create action is explicitly enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/",
|
||||
{"email": "invited@example.com", "role": models.RoleChoices.READER},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "invited@example.com"
|
||||
assert data["role"] == models.RoleChoices.READER
|
||||
assert str(data["document"]) == str(document.id)
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "partial_update"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_partial_update_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to partially update a document invitation
|
||||
when the partial_update action is explicitly enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
invitation = factories.InvitationFactory(
|
||||
document=document, role=models.RoleChoices.READER
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
|
||||
{"role": models.RoleChoices.EDITOR},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["role"] == models.RoleChoices.EDITOR
|
||||
assert data["email"] == invitation.email
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "destroy"],
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_external_api_document_invitations_delete_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to delete a document invitation
|
||||
when the destroy action is explicitly enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
|
||||
response = client.delete(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Tests for the Resource Server API for document link configurations.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_link_configuration_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to update the link configuration of a document
|
||||
from a resource server.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/link-configuration/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"link_configuration",
|
||||
],
|
||||
},
|
||||
},
|
||||
COLLABORATION_API_URL="http://example.com/",
|
||||
COLLABORATION_SERVER_SECRET="secret-token",
|
||||
)
|
||||
@patch("core.services.collaboration_services.CollaborationService.reset_connections")
|
||||
def test_external_api_documents_link_configuration_can_be_allowed(
|
||||
mock_reset, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to update the link configuration of a document
|
||||
from a resource server when the corresponding action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
# attempt to change reach/role to a valid combination
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.PUBLIC,
|
||||
"link_role": models.LinkRoleChoices.EDITOR,
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# verify the document was updated in the database
|
||||
document.refresh_from_db()
|
||||
assert document.link_reach == models.LinkReachChoices.PUBLIC
|
||||
assert document.link_role == models.LinkRoleChoices.EDITOR
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Tests for the Resource Server API for document media authentication.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.enums import DocumentAttachmentStatus
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_media_auth_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to access media auth endpoints
|
||||
from a resource server by default.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/documents/media-auth/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"media_auth",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_media_auth_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to access media auth endpoints
|
||||
from a resource server when the media-auth action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
key = f"{document_id!s}/attachments/{filename:s}"
|
||||
media_url = f"http://localhost/media/{key: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},
|
||||
)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
id=document_id, link_reach=models.LinkReachChoices.RESTRICTED, attachments=[key]
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.READER
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
with freeze_time(now):
|
||||
response = client.get(
|
||||
"/external_api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Tests for the Resource Server API for document versions.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_documents_versions_list_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list the versions of a document
|
||||
from a resource server by default.
|
||||
"""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
creator=user_specific_sub,
|
||||
)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=user_specific_sub,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_documents_versions_detail_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to retrieve a specific version of a document
|
||||
from a resource server by default.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/versions/1234/"
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# Overrides
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": ["list", "retrieve", "children", "versions_list"],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_versions_list_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to list version of a document from a resource server
|
||||
when the versions action is enabled in EXTERNAL_API settings.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Add new versions to the document
|
||||
for i in range(3):
|
||||
document.content = f"new content {i:d}"
|
||||
document.save()
|
||||
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.json()
|
||||
assert content["count"] == 2
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXTERNAL_API={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"children",
|
||||
"versions_list",
|
||||
"versions_detail",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_external_api_documents_versions_detail_can_be_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to retrieve a specific version of a document
|
||||
from a resource server when the versions_detail action is enabled.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# ensure access datetime is earlier than versions (minio precision is one second)
|
||||
time.sleep(1)
|
||||
|
||||
# create several versions, spacing them out to get distinct LastModified values
|
||||
for i in range(3):
|
||||
document.content = f"new content {i:d}"
|
||||
document.save()
|
||||
time.sleep(1)
|
||||
|
||||
# call the list endpoint and verify basic structure
|
||||
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.json()
|
||||
# count should reflect two saved versions beyond the original
|
||||
assert content.get("count") == 2
|
||||
|
||||
# pick the first version returned by the list (should be accessible)
|
||||
version_id = content.get("versions")[0]["version_id"]
|
||||
|
||||
detailed_response = client.get(
|
||||
f"/external_api/v1.0/documents/{document.id!s}/versions/{version_id}/"
|
||||
)
|
||||
assert detailed_response.status_code == 200
|
||||
assert detailed_response.json()["content"] == "new content 1"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Tests for the Resource Server API for users.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_external_api_users_me_anonymous_public_standalone():
|
||||
"""
|
||||
Anonymous users SHOULD NOT be allowed to retrieve their own user information from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
response = APIClient().get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_external_api_users_me_connected_not_allowed():
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to retrieve their own user information from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_external_api_users_me_connected_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD be allowed to retrieve their own user information from external API
|
||||
if resource server is enabled.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(user_specific_sub.id)
|
||||
assert data["email"] == user_specific_sub.email
|
||||
|
||||
|
||||
def test_external_api_users_me_connected_with_invalid_token_not_allowed(
|
||||
user_token, resource_server_backend
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to retrieve their own user information from external API
|
||||
if resource server is enabled with an invalid token.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# Non allowed actions on resource server.
|
||||
|
||||
|
||||
def test_external_api_users_list_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_users_retrieve_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to retrieve a specific user from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.get(f"/external_api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_users_put_patch_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to update or patch a user from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
new_user_values = {
|
||||
k: v
|
||||
for k, v in serializers.UserSerializer(
|
||||
instance=factories.UserFactory()
|
||||
).data.items()
|
||||
if v is not None
|
||||
}
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/users/{other_user.id!s}/", new_user_values
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.patch(
|
||||
f"/external_api/v1.0/users/{other_user.id!s}/",
|
||||
{"email": "new_email@example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_external_api_users_delete_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users SHOULD NOT be allowed to delete a user from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -48,7 +48,7 @@ def test_api_users_list_query_email():
|
||||
Only results with a Levenstein distance less than 3 with the query should be returned.
|
||||
We want to match by Levenstein distance because we want to prevent typing errors.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(email="user@example.com", full_name="Example User")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -83,7 +83,7 @@ def test_api_users_list_query_email_with_internationalized_domain_names():
|
||||
Authenticated users should be able to list users and filter by email.
|
||||
It should work even if the email address contains an internationalized domain name.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(email="user@example.com", full_name="Example User")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -123,7 +123,7 @@ def test_api_users_list_query_full_name():
|
||||
Authenticated users should be able to list users and filter by full name.
|
||||
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
|
||||
"""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory(email="user@example.com", full_name="Example User")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -168,7 +168,7 @@ def test_api_users_list_query_accented_full_name():
|
||||
Authenticated users should be able to list users and filter by full name with accents.
|
||||
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
|
||||
"""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory(email="user@example.com", full_name="Example User")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -416,7 +416,7 @@ def test_api_users_list_query_long_queries():
|
||||
|
||||
def test_api_users_list_query_inactive():
|
||||
"""Inactive users should not be listed."""
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
user = factories.UserFactory(email="user@example.com", full_name="Example User")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
|
||||
@@ -216,7 +216,13 @@ def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
|
||||
When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document,
|
||||
a new sandbox document should be created for the user with OWNER access.
|
||||
"""
|
||||
documents_before = factories.DocumentFactory.create_batch(20)
|
||||
template_document = factories.DocumentFactory(title="Getting started with Docs")
|
||||
documents_after = factories.DocumentFactory.create_batch(20)
|
||||
|
||||
all_documents = documents_before + [template_document] + documents_after
|
||||
|
||||
paths = {document.pk: document.path for document in all_documents}
|
||||
|
||||
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
|
||||
user = factories.UserFactory()
|
||||
@@ -233,6 +239,10 @@ def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
|
||||
access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc)
|
||||
assert access.role == models.RoleChoices.OWNER
|
||||
|
||||
for document in all_documents:
|
||||
document.refresh_from_db()
|
||||
assert document.path == paths[document.id]
|
||||
|
||||
|
||||
def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id():
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Utils for testing URLs."""
|
||||
|
||||
import importlib
|
||||
|
||||
from django.urls import clear_url_caches
|
||||
|
||||
|
||||
def reload_urls():
|
||||
"""
|
||||
Reload the URLs. Since the URLs are loaded based on a
|
||||
settings value, we need to reload them to make the
|
||||
URL settings based condition effective.
|
||||
"""
|
||||
import core.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
|
||||
|
||||
import impress.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
|
||||
|
||||
importlib.reload(core.urls)
|
||||
importlib.reload(impress.urls)
|
||||
clear_url_caches()
|
||||
@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import viewsets
|
||||
from core.external_api import viewsets as external_api_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -43,6 +44,19 @@ thread_related_router.register(
|
||||
basename="comments",
|
||||
)
|
||||
|
||||
# - Resource server routes
|
||||
external_api_router = DefaultRouter()
|
||||
external_api_router.register(
|
||||
"documents",
|
||||
external_api_viewsets.ResourceServerDocumentViewSet,
|
||||
basename="resource_server_documents",
|
||||
)
|
||||
external_api_router.register(
|
||||
"users",
|
||||
external_api_viewsets.ResourceServerUserViewSet,
|
||||
basename="resource_server_users",
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@@ -68,3 +82,38 @@ urlpatterns = [
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
]
|
||||
|
||||
if settings.OIDC_RESOURCE_SERVER_ENABLED:
|
||||
# - Routes nested under a document in external API
|
||||
external_api_document_related_router = DefaultRouter()
|
||||
|
||||
document_access_config = settings.EXTERNAL_API.get("document_access", {})
|
||||
if document_access_config.get("enabled", False):
|
||||
external_api_document_related_router.register(
|
||||
"accesses",
|
||||
external_api_viewsets.ResourceServerDocumentAccessViewSet,
|
||||
basename="resource_server_document_accesses",
|
||||
)
|
||||
|
||||
document_invitation_config = settings.EXTERNAL_API.get("document_invitation", {})
|
||||
if document_invitation_config.get("enabled", False):
|
||||
external_api_document_related_router.register(
|
||||
"invitations",
|
||||
external_api_viewsets.ResourceServerInvitationViewSet,
|
||||
basename="resource_server_document_invitations",
|
||||
)
|
||||
|
||||
urlpatterns.append(
|
||||
path(
|
||||
f"external_api/{settings.API_VERSION}/",
|
||||
include(
|
||||
[
|
||||
*external_api_router.urls,
|
||||
re_path(
|
||||
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
|
||||
include(external_api_document_related_router.urls),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -687,6 +687,109 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC Resource Server
|
||||
|
||||
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_BACKEND_CLASS = values.Value(
|
||||
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
|
||||
environ_name="OIDC_RS_BACKEND_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_CLIENT_ID = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_CLIENT_SECRET = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
||||
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_SCOPES = values.ListValue(
|
||||
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||
default="RSA",
|
||||
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External API Configuration
|
||||
# Configure available routes and actions for external_api endpoints
|
||||
EXTERNAL_API = values.DictValue(
|
||||
default={
|
||||
"documents": {
|
||||
"enabled": True,
|
||||
"actions": [
|
||||
"list",
|
||||
"retrieve",
|
||||
"create",
|
||||
"children",
|
||||
],
|
||||
},
|
||||
"document_access": {
|
||||
"enabled": False,
|
||||
"actions": [],
|
||||
},
|
||||
"document_invitation": {
|
||||
"enabled": False,
|
||||
"actions": [],
|
||||
},
|
||||
"users": {
|
||||
"enabled": True,
|
||||
"actions": ["get_me"],
|
||||
},
|
||||
},
|
||||
environ_name="EXTERNAL_API",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.8.1"
|
||||
version = "4.8.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
getMenuItem,
|
||||
mockedDocument,
|
||||
overrideConfig,
|
||||
verifyDocName,
|
||||
@@ -178,32 +179,18 @@ test.describe('Doc AI feature', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'AI', exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Rephrase' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Summarize' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Rephrase')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Summarize')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Correct')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Language')).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Language' }).hover();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'English', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'French', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'German', exact: true }),
|
||||
).toBeVisible();
|
||||
await getMenuItem(page, 'Language').hover();
|
||||
await expect(getMenuItem(page, 'English', { exact: true })).toBeVisible();
|
||||
await expect(getMenuItem(page, 'French', { exact: true })).toBeVisible();
|
||||
await expect(getMenuItem(page, 'German', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
|
||||
await getMenuItem(page, 'German', { exact: true }).click();
|
||||
|
||||
await expect(editor.getByText('Hallo Welt')).toBeVisible();
|
||||
});
|
||||
@@ -269,23 +256,15 @@ test.describe('Doc AI feature', () => {
|
||||
await page.getByRole('button', { name: 'AI', exact: true }).click();
|
||||
|
||||
if (ai_transform) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeHidden();
|
||||
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
|
||||
}
|
||||
|
||||
if (ai_translate) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Language')).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeHidden();
|
||||
await expect(getMenuItem(page, 'Language')).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
closeHeaderMenu,
|
||||
createDoc,
|
||||
getMenuItem,
|
||||
getOtherBrowserName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
@@ -150,7 +151,7 @@ test.describe('Doc Comments', () => {
|
||||
// Edit Comment
|
||||
await thread.getByText('This is a comment').first().hover();
|
||||
await thread.locator('[data-test="moreactions"]').first().click();
|
||||
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
|
||||
await getMenuItem(thread, 'Edit comment').click();
|
||||
const commentEditor = thread.getByText('This is a comment').first();
|
||||
await commentEditor.fill('This is an edited comment');
|
||||
const saveBtn = thread.locator('button[data-test="save"]').first();
|
||||
@@ -175,7 +176,7 @@ test.describe('Doc Comments', () => {
|
||||
// Delete second comment
|
||||
await thread.getByText('This is a second comment').first().hover();
|
||||
await thread.locator('[data-test="moreactions"]').first().click();
|
||||
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
|
||||
await getMenuItem(thread, 'Delete comment').click();
|
||||
await expect(
|
||||
thread.getByText('This is a second comment').first(),
|
||||
).toBeHidden();
|
||||
@@ -205,7 +206,7 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
await thread.getByText('This is a new comment').first().hover();
|
||||
await thread.locator('[data-test="moreactions"]').first().click();
|
||||
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
|
||||
await getMenuItem(thread, 'Delete comment').click();
|
||||
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
|
||||
@@ -5,6 +5,7 @@ import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
getMenuItem,
|
||||
goToGridDoc,
|
||||
overrideConfig,
|
||||
verifyDocName,
|
||||
@@ -147,7 +148,7 @@ test.describe('Doc Editor', () => {
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page.getByRole('menuitem', { name: 'Connected' }).click();
|
||||
await getMenuItem(page, 'Connected').click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
@@ -608,7 +609,7 @@ test.describe('Doc Editor', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByRole('menuitem', { name: 'Reading' }).click();
|
||||
await getMenuItem(page, 'Reading').click();
|
||||
|
||||
// Close the modal
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
createDoc,
|
||||
getGridRow,
|
||||
getMenuItem,
|
||||
getOtherBrowserName,
|
||||
mockedListDocs,
|
||||
toggleHeaderMenu,
|
||||
@@ -206,7 +207,7 @@ test.describe('Doc grid move', () => {
|
||||
const row = await getGridRow(page, titleDoc1);
|
||||
await row.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||
await getMenuItem(page, 'Move into a doc').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
||||
@@ -294,7 +295,7 @@ test.describe('Doc grid move', () => {
|
||||
const row = await getGridRow(page, titleDoc1);
|
||||
await row.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||
await getMenuItem(page, 'Move into a doc').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
||||
@@ -341,7 +342,7 @@ test.describe('Doc grid move', () => {
|
||||
`doc-share-access-request-row-${emailRequest}`,
|
||||
);
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await otherPage.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await getMenuItem(otherPage, 'Administrator').click();
|
||||
await container.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
await expect(otherPage.getByText('Access Requests')).toBeHidden();
|
||||
@@ -352,7 +353,7 @@ test.describe('Doc grid move', () => {
|
||||
await page.reload();
|
||||
await row.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||
await getMenuItem(page, 'Move into a doc').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, getGridRow, verifyDocName } from './utils-common';
|
||||
import {
|
||||
createDoc,
|
||||
getGridRow,
|
||||
getMenuItem,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||
|
||||
type SmallDoc = {
|
||||
@@ -99,7 +104,7 @@ test.describe('Document grid item options', () => {
|
||||
const row = await getGridRow(page, docTitle);
|
||||
await row.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await getMenuItem(page, 'Share').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByText('Share the document'),
|
||||
@@ -115,7 +120,7 @@ test.describe('Document grid item options', () => {
|
||||
|
||||
// Pin
|
||||
await row.getByText(`more_horiz`).click();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
await getMenuItem(page, 'Pin').click();
|
||||
|
||||
// Check is pinned
|
||||
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
|
||||
@@ -142,7 +147,7 @@ test.describe('Document grid item options', () => {
|
||||
const row = await getGridRow(page, docTitle);
|
||||
await row.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await getMenuItem(page, 'Delete').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
createDoc,
|
||||
getGridRow,
|
||||
getMenuItem,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
@@ -78,11 +79,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await page.getByTestId('doc-visibility').click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Public').click();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
|
||||
@@ -156,10 +153,8 @@ test.describe('Doc Header', () => {
|
||||
|
||||
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
|
||||
const optionMenu = page.getByLabel('Open the document options');
|
||||
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
|
||||
const removeEmojiMenuItem = page.getByRole('menuitem', {
|
||||
name: 'Remove emoji',
|
||||
});
|
||||
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
|
||||
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
|
||||
|
||||
// Top parent should not have emoji picker
|
||||
await expect(emojiPicker).toBeHidden();
|
||||
@@ -213,7 +208,7 @@ test.describe('Doc Header', () => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete document' }).click();
|
||||
await getMenuItem(page, 'Delete document').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
@@ -275,9 +270,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -285,7 +278,7 @@ test.describe('Doc Header', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share modal content',
|
||||
name: 'Share the document',
|
||||
});
|
||||
await expect(shareModal).toBeVisible();
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
@@ -300,7 +293,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await invitationRole.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await getMenuItem(page, 'Remove access').click();
|
||||
await expect(invitationCard).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
@@ -312,9 +305,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(roles).toBeVisible();
|
||||
|
||||
await roles.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove access' }),
|
||||
).toBeEnabled();
|
||||
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
@@ -354,9 +345,7 @@ test.describe('Doc Header', () => {
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -364,7 +353,7 @@ test.describe('Doc Header', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share modal content',
|
||||
name: 'Share the document',
|
||||
});
|
||||
await expect(shareModal).toBeVisible();
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
@@ -426,16 +415,16 @@ test.describe('Doc Header', () => {
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share the document',
|
||||
});
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
@@ -484,7 +473,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
|
||||
await getMenuItem(page, 'Copy as Markdown').click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
@@ -546,7 +535,7 @@ test.describe('Doc Header', () => {
|
||||
.click();
|
||||
|
||||
// Pin
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
await getMenuItem(page, 'Pin').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
@@ -567,11 +556,11 @@ test.describe('Doc Header', () => {
|
||||
.click();
|
||||
|
||||
// Unpin
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
await getMenuItem(page, 'Unpin').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Pin')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
@@ -589,7 +578,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await getMenuItem(page, 'Duplicate').click();
|
||||
await expect(
|
||||
page.getByText('Document duplicated successfully!'),
|
||||
).toBeVisible();
|
||||
@@ -604,7 +593,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(row.getByText(duplicateTitle)).toBeVisible();
|
||||
|
||||
await row.getByText(`more_horiz`).click();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await getMenuItem(page, 'Duplicate').click();
|
||||
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
|
||||
await page.getByText(duplicateDuplicateTitle).click();
|
||||
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
|
||||
@@ -637,7 +626,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await getMenuItem(page, 'Duplicate').click();
|
||||
|
||||
await expect(page).not.toHaveURL(new RegExp(currentUrl));
|
||||
|
||||
@@ -676,10 +665,8 @@ test.describe('Documents Header mobile', () => {
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Copy link' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
|
||||
await getMenuItem(page, 'Share').click();
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -702,13 +689,15 @@ test.describe('Documents Header mobile', () => {
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await getMenuItem(page, 'Share').click();
|
||||
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share modal content',
|
||||
name: 'Share the document',
|
||||
});
|
||||
await expect(shareModal).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await expect(page.getByLabel('Share modal')).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Share the document' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
|
||||
import { updateShareLink } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -53,19 +53,17 @@ test.describe('Inherited share accesses', () => {
|
||||
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
|
||||
|
||||
await docVisibilityCard.getByText('Reading').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
await getMenuItem(page, 'Editing').click();
|
||||
|
||||
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
|
||||
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
|
||||
|
||||
// Verify inherited link
|
||||
await docVisibilityCard.getByText('Connected').click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Private' }),
|
||||
).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Private')).toBeDisabled();
|
||||
|
||||
// Update child link
|
||||
await page.getByRole('menuitem', { name: 'Public' }).click();
|
||||
await getMenuItem(page, 'Public').click();
|
||||
|
||||
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
|
||||
await expect(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
BROWSERS,
|
||||
createDoc,
|
||||
getMenuItem,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
@@ -75,15 +76,13 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Check roles are displayed
|
||||
await list.getByTestId('doc-role-dropdown').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Reader')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Editor')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Owner')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
|
||||
|
||||
// Validate
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await getMenuItem(page, 'Administrator').click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
|
||||
// Check invitation added
|
||||
@@ -129,7 +128,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
await getMenuItem(page, 'Owner').click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -147,7 +146,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Choose a role
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
await getMenuItem(page, 'Owner').click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -184,7 +183,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await getMenuItem(page, 'Administrator').click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -211,13 +210,13 @@ test.describe('Document create member', () => {
|
||||
);
|
||||
|
||||
await userInvitation.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
await getMenuItem(page, 'Reader').click();
|
||||
|
||||
const responsePatchInvitation = await responsePromisePatchInvitation;
|
||||
expect(responsePatchInvitation.ok()).toBeTruthy();
|
||||
|
||||
await userInvitation.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await getMenuItem(page, 'Remove access').click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
@@ -269,7 +268,7 @@ test.describe('Document create member', () => {
|
||||
`doc-share-access-request-row-${emailRequest}`,
|
||||
);
|
||||
await container.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await getMenuItem(page, 'Administrator').click();
|
||||
await container.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
await expect(page.getByText('Access Requests')).toBeHidden();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
|
||||
import { addNewMember } from './utils-share';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -160,9 +160,7 @@ test.describe('Document list members', () => {
|
||||
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
|
||||
);
|
||||
await expect(soloOwner).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Administrator' }),
|
||||
).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
|
||||
|
||||
await list.click({
|
||||
force: true, // Force click to close the dropdown
|
||||
@@ -185,18 +183,18 @@ test.describe('Document list members', () => {
|
||||
});
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await getMenuItem(page, 'Administrator').click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
await newUserRoles.click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
|
||||
await list.click({
|
||||
force: true, // Force click to close the dropdown
|
||||
});
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
await getMenuItem(page, 'Reader').click();
|
||||
await list.click({
|
||||
force: true, // Force click to close the dropdown
|
||||
});
|
||||
@@ -236,11 +234,11 @@ test.describe('Document list members', () => {
|
||||
await expect(userReader).toBeVisible();
|
||||
|
||||
await userReaderRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await getMenuItem(page, 'Remove access').click();
|
||||
await expect(userReader).toBeHidden();
|
||||
|
||||
await mySelfRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await getMenuItem(page, 'Remove access').click();
|
||||
await expect(
|
||||
page.getByText('Insufficient access rights to view the document.'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -136,13 +136,9 @@ test.describe('Document search', () => {
|
||||
|
||||
await filters.click();
|
||||
await filters.getByRole('button', { name: 'Current doc' }).click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'All docs' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Current doc' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'All docs' }).click();
|
||||
await expect(getMenuItem(page, 'All docs')).toBeVisible();
|
||||
await expect(getMenuItem(page, 'Current doc')).toBeVisible();
|
||||
await getMenuItem(page, 'All docs').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
createDoc,
|
||||
expectLoginPage,
|
||||
getMenuItem,
|
||||
keyCloakSignIn,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
@@ -162,7 +163,7 @@ test.describe('Doc Tree', () => {
|
||||
);
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await getMenuItem(page, 'Administrator').click();
|
||||
await list.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Ok' }).click();
|
||||
@@ -192,9 +193,10 @@ test.describe('Doc Tree', () => {
|
||||
const menu = child.getByText(`more_horiz`);
|
||||
await menu.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Move to my docs' }),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
test('keyboard navigation with Enter key opens documents', async ({
|
||||
@@ -297,7 +299,7 @@ test.describe('Doc Tree', () => {
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
|
||||
await expect(page.getByLabel('Open help menu')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
@@ -307,7 +309,7 @@ test.describe('Doc Tree', () => {
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
|
||||
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
|
||||
await expect(page.getByLabel('Open help menu')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
|
||||
@@ -338,9 +340,7 @@ test.describe('Doc Tree', () => {
|
||||
await row.hover();
|
||||
const menu = row.getByText(`more_horiz`);
|
||||
await menu.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove emoji' }),
|
||||
).toBeHidden();
|
||||
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -360,7 +360,7 @@ test.describe('Doc Tree', () => {
|
||||
// Now remove the emoji using the new action
|
||||
await row.hover();
|
||||
await menu.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
|
||||
await getMenuItem(page, 'Remove emoji').click();
|
||||
|
||||
await expect(row.getByText('😀')).toBeHidden();
|
||||
await expect(titleEmojiPicker).toBeHidden();
|
||||
@@ -390,11 +390,7 @@ test.describe('Doc Tree: Inheritance', () => {
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Public').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
getMenuItem,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
@@ -20,10 +21,10 @@ test.describe('Doc Version', () => {
|
||||
|
||||
// Initially, there is no version
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
await getMenuItem(page, 'Version history').click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
@@ -74,7 +75,7 @@ test.describe('Doc Version', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
await getMenuItem(page, 'Version history').click();
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
@@ -124,9 +125,7 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Version history' }),
|
||||
).toBeDisabled();
|
||||
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
@@ -153,9 +152,9 @@ test.describe('Doc Version', () => {
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
await getMenuItem(page, 'Version history').click();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BROWSERS,
|
||||
createDoc,
|
||||
expectLoginPage,
|
||||
getMenuItem,
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
@@ -46,21 +47,17 @@ test.describe('Doc Visibility', () => {
|
||||
|
||||
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Read only' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Can read and edit' }),
|
||||
).toBeHidden();
|
||||
await expect(getMenuItem(page, 'Read only')).toBeHidden();
|
||||
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page.getByRole('menuitem', { name: 'Connected' }).click();
|
||||
await getMenuItem(page, 'Connected').click();
|
||||
|
||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Public' }).click();
|
||||
await getMenuItem(page, 'Public').click();
|
||||
|
||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||
});
|
||||
@@ -205,11 +202,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Public').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
@@ -217,11 +210,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Reading',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Reading').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
@@ -307,18 +296,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Public').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
await getMenuItem(page, 'Editing').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
@@ -402,11 +387,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Connected').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
@@ -454,11 +435,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Connected').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
@@ -556,11 +533,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await getMenuItem(page, 'Connected').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
@@ -568,7 +541,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
const urlDoc = page.url();
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
await getMenuItem(page, 'Editing').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { overrideConfig } from './utils-common';
|
||||
import { getMenuItem, overrideConfig } from './utils-common';
|
||||
|
||||
test.describe('Footer', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
|
||||
// Check the translation
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button').getByText('English').click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
await getMenuItem(page, 'Français').click();
|
||||
|
||||
await expect(
|
||||
page.locator('footer').getByText('Mentions légales'),
|
||||
@@ -131,7 +131,7 @@ test.describe('Footer', () => {
|
||||
// Check the translation
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button').getByText('English').click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
await getMenuItem(page, 'Français').click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -55,7 +55,8 @@ test.describe('Header', () => {
|
||||
'src',
|
||||
'/assets/icon-docs-v2.svg',
|
||||
);
|
||||
await expect(header.locator('h1')).toBeHidden();
|
||||
// With withTitle: false, the h1 is kept for accessibility but visually hidden via sr-only
|
||||
await expect(header.locator('h1').getByText('Docs')).toHaveClass(/sr-only/);
|
||||
});
|
||||
|
||||
test('checks a custom waffle', async ({ page }) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
TestLanguage,
|
||||
getMenuItem,
|
||||
overrideConfig,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
@@ -26,7 +27,7 @@ test.describe('Help feature', () => {
|
||||
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Open onboarding menu' }),
|
||||
page.getByRole('button', { name: 'Open help menu' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -42,9 +43,9 @@ test.describe('Help feature', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
|
||||
await page.getByRole('button', { name: 'Open help menu' }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
|
||||
await getMenuItem(page, 'Onboarding').click();
|
||||
|
||||
const modal = page.getByTestId('onboarding-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
@@ -86,8 +87,8 @@ test.describe('Help feature', () => {
|
||||
});
|
||||
|
||||
test('closes modal with Skip button', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
|
||||
await page.getByRole('button', { name: 'Open help menu' }).click();
|
||||
await getMenuItem(page, 'Onboarding').click();
|
||||
|
||||
const modal = page.getByTestId('onboarding-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
@@ -106,11 +107,9 @@ test.describe('Help feature', () => {
|
||||
// switch to french
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: "Ouvrir le menu d'embarquement" })
|
||||
.click();
|
||||
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Premiers pas' }).click();
|
||||
await getMenuItem(page, 'Premiers pas').click();
|
||||
|
||||
const modal = page.getByLabel('Apprenez les principes fondamentaux');
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ test.describe('Language', () => {
|
||||
|
||||
await expect(page.locator('[role="menu"]')).toBeVisible();
|
||||
|
||||
const menuItems = page.getByRole('menuitem');
|
||||
const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
|
||||
await expect(menuItems.first()).toBeVisible();
|
||||
|
||||
await menuItems.first().click();
|
||||
|
||||
@@ -3,6 +3,16 @@ import path from 'path';
|
||||
|
||||
import { Locator, Page, TestInfo, expect } from '@playwright/test';
|
||||
|
||||
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
|
||||
export const getMenuItem = (
|
||||
context: Page | Locator,
|
||||
name: string,
|
||||
options?: { exact?: boolean },
|
||||
): Locator =>
|
||||
context
|
||||
.getByRole('menuitem', { name, exact: options?.exact })
|
||||
.or(context.getByRole('menuitemradio', { name, exact: options?.exact }));
|
||||
|
||||
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
|
||||
|
||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
@@ -382,12 +392,12 @@ export async function waitForLanguageSwitch(
|
||||
|
||||
await languagePicker.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: lang.label }).click();
|
||||
await getMenuItem(page, lang.label).click();
|
||||
}
|
||||
|
||||
export const clickInEditorMenu = async (page: Page, textButton: string) => {
|
||||
await page.getByRole('button', { name: 'Open the document options' }).click();
|
||||
await page.getByRole('menuitem', { name: textButton }).click();
|
||||
await getMenuItem(page, textButton).click();
|
||||
};
|
||||
|
||||
export const clickInGridMenu = async (
|
||||
@@ -398,7 +408,7 @@ export const clickInGridMenu = async (
|
||||
await row
|
||||
.getByRole('button', { name: /Open the menu of actions for the document/ })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: textButton }).click();
|
||||
await getMenuItem(page, textButton).click();
|
||||
};
|
||||
|
||||
export const writeReport = async (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Page, chromium, expect } from '@playwright/test';
|
||||
|
||||
import {
|
||||
BrowserName,
|
||||
getMenuItem,
|
||||
getOtherBrowserName,
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
@@ -39,7 +40,7 @@ export const addNewMember = async (
|
||||
|
||||
// Choose a role
|
||||
await page.getByTestId('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await getMenuItem(page, role).click();
|
||||
await page.getByTestId('doc-share-invite-button').click();
|
||||
|
||||
return users[index].email;
|
||||
@@ -51,7 +52,7 @@ export const updateShareLink = async (
|
||||
linkRole?: LinkRole | null,
|
||||
) => {
|
||||
await page.getByTestId('doc-visibility').click();
|
||||
await page.getByRole('menuitem', { name: linkReach }).click();
|
||||
await getMenuItem(page, linkReach).click();
|
||||
|
||||
const visibilityUpdatedText = page
|
||||
.getByText('The document visibility has been updated')
|
||||
@@ -61,7 +62,7 @@ export const updateShareLink = async (
|
||||
|
||||
if (linkRole) {
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByRole('menuitem', { name: linkRole }).click();
|
||||
await getMenuItem(page, linkRole).click();
|
||||
await expect(visibilityUpdatedText).toBeVisible();
|
||||
}
|
||||
};
|
||||
@@ -76,7 +77,7 @@ export const updateRoleUser = async (
|
||||
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await getMenuItem(page, role).click();
|
||||
await list.click();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -46,6 +46,7 @@
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@mantine/core": "8.3.14",
|
||||
"@mantine/hooks": "8.3.14",
|
||||
"@react-aria/live-announcer": "3.4.4",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
|
||||
@@ -93,6 +93,8 @@ export const DropButton = ({
|
||||
onOpenChangeHandler(true);
|
||||
}}
|
||||
aria-label={label}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isLocalOpen}
|
||||
data-testid={testId}
|
||||
$css={css`
|
||||
font-family: ${font};
|
||||
|
||||
@@ -110,6 +110,10 @@ export const DropdownMenu = ({
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
const hasSelectable =
|
||||
selectedValues !== undefined ||
|
||||
options.some((option) => option.isSelected !== undefined);
|
||||
|
||||
if (disabled) {
|
||||
return children;
|
||||
}
|
||||
@@ -172,6 +176,11 @@ export const DropdownMenu = ({
|
||||
}
|
||||
const isDisabled = option.disabled !== undefined && option.disabled;
|
||||
const isFocused = index === focusedIndex;
|
||||
const ariaChecked = hasSelectable
|
||||
? option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '') ||
|
||||
false
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Fragment key={option.label}>
|
||||
@@ -179,7 +188,8 @@ export const DropdownMenu = ({
|
||||
ref={(el) => {
|
||||
menuItemRefs.current[index] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
|
||||
aria-checked={ariaChecked}
|
||||
data-testid={option.testId}
|
||||
$direction="row"
|
||||
disabled={isDisabled}
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
|
||||
|
||||
const mockAddLastFocus = vi.fn();
|
||||
vi.mock('@/stores', () => ({
|
||||
useFocusStore: (selector: any) =>
|
||||
selector({ addLastFocus: mockAddLastFocus }),
|
||||
useResponsiveStore: () => ({ isDesktop: true }),
|
||||
}));
|
||||
|
||||
const baseOptions: DropdownMenuOption[] = [
|
||||
{ label: 'English', callback: vi.fn() },
|
||||
{ label: 'Français', callback: vi.fn() },
|
||||
{ label: 'Deutsch', callback: vi.fn() },
|
||||
];
|
||||
|
||||
const selectableOptions: DropdownMenuOption[] = [
|
||||
{ label: 'English', isSelected: false, callback: vi.fn() },
|
||||
{ label: 'Français', isSelected: true, callback: vi.fn() },
|
||||
{ label: 'Deutsch', isSelected: false, callback: vi.fn() },
|
||||
];
|
||||
|
||||
describe('<DropdownMenu />', () => {
|
||||
test('renders menuitem role when options have no selection', async () => {
|
||||
render(
|
||||
<DropdownMenu options={baseOptions} label="Options" opened>
|
||||
Open menu
|
||||
</DropdownMenu>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
expect(items).toHaveLength(3);
|
||||
items.forEach((item) => {
|
||||
expect(item).not.toHaveAttribute('aria-checked');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders menuitemradio role with aria-checked when options have isSelected', async () => {
|
||||
render(
|
||||
<DropdownMenu options={selectableOptions} label="Select language" opened>
|
||||
Français
|
||||
</DropdownMenu>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
const radios = screen.getAllByRole('menuitemradio');
|
||||
expect(radios).toHaveLength(3);
|
||||
|
||||
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
|
||||
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
|
||||
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
test('renders menuitemradio role with aria-checked when selectedValues is provided', async () => {
|
||||
const optionsWithValues: DropdownMenuOption[] = [
|
||||
{ label: 'English', value: 'en', callback: vi.fn() },
|
||||
{ label: 'Français', value: 'fr', callback: vi.fn() },
|
||||
{ label: 'Deutsch', value: 'de', callback: vi.fn() },
|
||||
];
|
||||
|
||||
render(
|
||||
<DropdownMenu
|
||||
options={optionsWithValues}
|
||||
selectedValues={['fr']}
|
||||
label="Select language"
|
||||
opened
|
||||
>
|
||||
Français
|
||||
</DropdownMenu>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
const radios = screen.getAllByRole('menuitemradio');
|
||||
expect(radios).toHaveLength(3);
|
||||
|
||||
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
|
||||
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
|
||||
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
test('trigger button has aria-haspopup and aria-expanded', async () => {
|
||||
render(
|
||||
<DropdownMenu options={baseOptions} label="Select language">
|
||||
Français
|
||||
</DropdownMenu>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Select language' });
|
||||
expect(trigger).toHaveAttribute('aria-haspopup', 'true');
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await waitFor(() => {
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('displays check icon for selected item', async () => {
|
||||
render(
|
||||
<DropdownMenu options={selectableOptions} label="Select language" opened>
|
||||
Français
|
||||
</DropdownMenu>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
const menu = screen.getByRole('menu');
|
||||
const checkedItem = within(menu).getAllByRole('menuitemradio')[1];
|
||||
expect(checkedItem).toHaveAttribute('aria-checked', 'true');
|
||||
expect(within(checkedItem).getByText('Français')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export const FilterDropdown = ({
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
<Text $weight={400} $variation="tertiary" $theme="neutral">
|
||||
<Text $weight={400} $variation="tertiary" $theme="neutral" $size="sm">
|
||||
{selectedOption?.label ?? options[0].label}
|
||||
</Text>
|
||||
<Icon
|
||||
|
||||
@@ -56,7 +56,7 @@ export const AlertModal = ({
|
||||
isOpen={isOpen}
|
||||
size={ModalSize.MEDIUM}
|
||||
onClose={onClose}
|
||||
aria-describedby="alert-modal-title"
|
||||
aria-label={title}
|
||||
title={
|
||||
<Text
|
||||
$size="h6"
|
||||
|
||||
@@ -19,9 +19,11 @@ export const QuickSearchGroup = <T,>({
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text as="h2" $weight="700" $size="sm" $margin="none">
|
||||
{group.groupName}
|
||||
</Text>
|
||||
<Command.Group
|
||||
key={group.groupName}
|
||||
heading={group.groupName}
|
||||
forceMount={false}
|
||||
contentEditable={false}
|
||||
>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const QuickSearchInput = ({
|
||||
$align="center"
|
||||
className="quick-search-input"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$padding={{ horizontal: 'base', vertical: 'xxs' }}
|
||||
$padding={{ horizontal: 'base', vertical: 'xs' }}
|
||||
>
|
||||
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
|
||||
<Command.Input
|
||||
@@ -58,7 +58,7 @@ export const QuickSearchInput = ({
|
||||
data-testid="quick-search-input"
|
||||
/>
|
||||
</Box>
|
||||
{separator && <HorizontalSeparator $margin={{ top: 'base' }} />}
|
||||
{separator && <HorizontalSeparator $margin={{ top: '2xs' }} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,10 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
}
|
||||
|
||||
[cmdk-input] {
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
border: none;
|
||||
width: 100%;
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
outline: none;
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
|
||||
+3
-6
@@ -19,7 +19,7 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
aria-describedby="modal-confirm-download-unsafe-title"
|
||||
aria-label={t('Warning')}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
@@ -48,7 +48,7 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<Text
|
||||
as="h1"
|
||||
as="h2"
|
||||
id="modal-confirm-download-unsafe-title"
|
||||
$gap="0.7rem"
|
||||
$size="h6"
|
||||
@@ -61,10 +61,7 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Modal confirmation to download the attachment')}
|
||||
className="--docs--modal-confirm-download-unsafe"
|
||||
>
|
||||
<Box className="--docs--modal-confirm-download-unsafe">
|
||||
<Box>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="secondary">
|
||||
|
||||
+12
-21
@@ -57,30 +57,22 @@ export const accessibleImageRender =
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
|
||||
const figureElement = document.createElement('figure');
|
||||
const originalCaption = dom.querySelector('.bn-file-caption');
|
||||
|
||||
// Copy all attributes from the original div
|
||||
figureElement.className = dom.className;
|
||||
const styleAttr = dom.getAttribute('style');
|
||||
if (styleAttr) {
|
||||
figureElement.setAttribute('style', styleAttr);
|
||||
}
|
||||
figureElement.style.setProperty('margin', '0');
|
||||
if (imgSelector?.parentNode && originalCaption) {
|
||||
const figureElement = document.createElement('figure');
|
||||
figureElement.style.setProperty('margin', '0');
|
||||
|
||||
Array.from(dom.children).forEach((child) => {
|
||||
figureElement.appendChild(child.cloneNode(true));
|
||||
});
|
||||
// Wrap only the img inside figure, preserving the rest of the dom tree
|
||||
imgSelector.parentNode.insertBefore(figureElement, imgSelector);
|
||||
figureElement.appendChild(imgSelector);
|
||||
|
||||
// Replace the <p> caption with <figcaption>
|
||||
const figcaptionElement = document.createElement('figcaption');
|
||||
const originalCaption = figureElement.querySelector('.bn-file-caption');
|
||||
if (originalCaption) {
|
||||
// Replace the <p> caption with <figcaption> inside the figure
|
||||
const figcaptionElement = document.createElement('figcaption');
|
||||
figcaptionElement.className = originalCaption.className;
|
||||
figcaptionElement.textContent = originalCaption.textContent;
|
||||
originalCaption.parentNode?.replaceChild(
|
||||
figcaptionElement,
|
||||
originalCaption,
|
||||
);
|
||||
figureElement.appendChild(figcaptionElement);
|
||||
originalCaption.parentNode?.removeChild(originalCaption);
|
||||
|
||||
// Add explicit role and aria-label for better screen reader support
|
||||
figureElement.setAttribute('role', 'img');
|
||||
@@ -90,10 +82,9 @@ export const accessibleImageRender =
|
||||
);
|
||||
}
|
||||
|
||||
// Return the figure element as the new dom
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
hideCloseButton
|
||||
aria-label={t('Export')}
|
||||
aria-describedby="modal-export-title"
|
||||
rightActions={
|
||||
<>
|
||||
@@ -247,7 +248,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
>
|
||||
<Box
|
||||
$margin={{ bottom: 'xl' }}
|
||||
aria-label={t('Content modal to export the document')}
|
||||
$gap="1rem"
|
||||
className="--docs--modal-export-content"
|
||||
>
|
||||
|
||||
@@ -73,6 +73,7 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
aria-label={t("Why you can't edit the document?")}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
@@ -92,11 +93,7 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Content modal to explain why the user cannot edit')}
|
||||
className="--docs--modal-alert-network"
|
||||
$margin={{ top: 'md' }}
|
||||
>
|
||||
<Box className="--docs--modal-alert-network" $margin={{ top: 'md' }}>
|
||||
<Text $size="sm" $variation="secondary">
|
||||
{t(
|
||||
'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
|
||||
|
||||
@@ -55,7 +55,9 @@ export const BoutonShare = ({
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('Share button')}
|
||||
aria-label={t('Share button, shared with {{count}} users', {
|
||||
count: doc.nb_accesses_direct,
|
||||
})}
|
||||
variant="secondary"
|
||||
icon={
|
||||
<Icon
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export const ModalRemoveDoc = ({
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
onClose={handleClose}
|
||||
aria-describedby="modal-remove-doc-title"
|
||||
aria-label={t('Delete a doc')}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
|
||||
+3
-3
@@ -38,19 +38,19 @@ export const DocSearchFilters = ({
|
||||
$justify="space-between"
|
||||
$gap="10px"
|
||||
data-testid="doc-search-filters"
|
||||
$margin={{ vertical: 'base' }}
|
||||
$margin={{ vertical: 'sm' }}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="10px">
|
||||
<FilterDropdown
|
||||
selectedValue={values?.target}
|
||||
options={[
|
||||
{
|
||||
label: t('All docs'),
|
||||
label: t('All documents'),
|
||||
value: DocSearchTarget.ALL,
|
||||
callback: () => handleTargetChange(DocSearchTarget.ALL),
|
||||
},
|
||||
{
|
||||
label: t('Current doc'),
|
||||
label: t('Current document only'),
|
||||
value: DocSearchTarget.CURRENT,
|
||||
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
|
||||
},
|
||||
|
||||
+18
-20
@@ -60,38 +60,36 @@ const DocSearchModalGlobal = ({
|
||||
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
|
||||
hideCloseButton
|
||||
aria-describedby="doc-search-modal-title"
|
||||
title={
|
||||
<>
|
||||
<Text as="h2" $margin="0" $size="s" $align="flex-start">
|
||||
{t('Search for a document')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the search modal')}
|
||||
onClick={modalProps.onClose}
|
||||
size="small"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Search modal')}
|
||||
$direction="column"
|
||||
$justify="space-between"
|
||||
className="--docs--doc-search-modal"
|
||||
$padding={{ vertical: 'base' }}
|
||||
$padding={{ bottom: 'base' }}
|
||||
>
|
||||
<Text
|
||||
as="h1"
|
||||
$margin="0"
|
||||
id="doc-search-modal-title"
|
||||
className="sr-only"
|
||||
>
|
||||
{t('Search docs')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the search modal')}
|
||||
onClick={modalProps.onClose}
|
||||
size="small"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
/>
|
||||
</Box>
|
||||
<QuickSearch
|
||||
placeholder={t('Type the name of a document')}
|
||||
loading={loading}
|
||||
onFilter={handleInputSearch}
|
||||
>
|
||||
<Box
|
||||
$padding={{ horizontal: '10px', vertical: 'base' }}
|
||||
$padding={{ horizontal: 'sm', vertical: 'base' }}
|
||||
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
|
||||
>
|
||||
{showFilters && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { announce } from '@react-aria/live-announcer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -80,7 +81,6 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [liveAnnouncement, setLiveAnnouncement] = useState('');
|
||||
|
||||
const [listHeight, setListHeight] = useState<string>('400px');
|
||||
const canShare = doc.abilities.accesses_manage && isRootDoc;
|
||||
@@ -93,18 +93,16 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
setUserQuery('');
|
||||
setInputValue('');
|
||||
|
||||
// Announce to screen readers
|
||||
const userName = user.full_name || user.email;
|
||||
setLiveAnnouncement(
|
||||
announce(
|
||||
t(
|
||||
'{{name}} added to invite list. Add more members or press Tab to select role and invite.',
|
||||
{
|
||||
name: userName,
|
||||
},
|
||||
),
|
||||
'polite',
|
||||
);
|
||||
// Clear announcement after it's been read
|
||||
setTimeout(() => setLiveAnnouncement(''), 100);
|
||||
};
|
||||
|
||||
const { data: membersQuery } = useDocAccesses({
|
||||
@@ -132,14 +130,13 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const newArray = [...prevState];
|
||||
newArray.splice(index, 1);
|
||||
|
||||
// Announce to screen readers
|
||||
const userName = row.full_name || row.email;
|
||||
setLiveAnnouncement(
|
||||
announce(
|
||||
t('{{name}} removed from invite list', {
|
||||
name: userName,
|
||||
}),
|
||||
'polite',
|
||||
);
|
||||
setTimeout(() => setLiveAnnouncement(''), 100);
|
||||
|
||||
return newArray;
|
||||
});
|
||||
@@ -183,7 +180,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
data-testid="doc-share-modal"
|
||||
aria-labelledby="doc-share-modal-title"
|
||||
aria-label={t('Share the document')}
|
||||
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
|
||||
aria-modal="true"
|
||||
onClose={onClose}
|
||||
@@ -208,23 +205,12 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
hideCloseButton
|
||||
>
|
||||
<ShareModalStyle />
|
||||
{/* Screen reader announcements */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{liveAnnouncement}
|
||||
</div>
|
||||
<Box
|
||||
$height="auto"
|
||||
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
|
||||
$overflow="hidden"
|
||||
className="--docs--doc-share-modal noPadding "
|
||||
$justify="space-between"
|
||||
role="dialog"
|
||||
aria-label={t('Share modal content')}
|
||||
>
|
||||
<Box
|
||||
$flex={1}
|
||||
|
||||
@@ -137,7 +137,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
$gap={spacingsTokens['base']}
|
||||
className="--docs--doc-visibility"
|
||||
>
|
||||
<Text $weight="700" $size="sm">
|
||||
<Text as="h2" $weight="700" $size="sm" $margin="none">
|
||||
{t('Link settings')}
|
||||
</Text>
|
||||
{isDesynchronized && <DocDesynchronized doc={doc} />}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useMoveDoc,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
|
||||
|
||||
import { CLASS_DOC_TITLE } from '../../doc-header';
|
||||
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
|
||||
@@ -257,7 +258,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
}, [currentDoc, treeContext]);
|
||||
|
||||
if (!treeContext || !treeContext.root) {
|
||||
return null;
|
||||
return <TreeSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export const ModalConfirmationVersion = ({
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
aria-describedby="modal-confirmation-version-title"
|
||||
aria-label={t('Warning')}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
|
||||
+1
-2
@@ -60,11 +60,10 @@ export const ModalSelectVersion = ({
|
||||
closeOnClickOutside={true}
|
||||
size={ModalSize.EXTRA_LARGE}
|
||||
onClose={onClose}
|
||||
aria-describedby="modal-select-version-title"
|
||||
aria-label={t('Version history')}
|
||||
>
|
||||
<NoPaddingStyle />
|
||||
<Box
|
||||
aria-label="version history modal"
|
||||
className="--docs--modal-select-version noPadding"
|
||||
$direction="row"
|
||||
$height="100%"
|
||||
|
||||
@@ -196,7 +196,6 @@ export const DocMoveModal = ({
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Move modal')}
|
||||
$direction="column"
|
||||
$justify="space-between"
|
||||
className="--docs--doc-move-modal"
|
||||
|
||||
@@ -76,7 +76,10 @@ export const Header = () => {
|
||||
{...(({ withTitle: _, ...rest }) => rest)(icon)}
|
||||
/>
|
||||
)}
|
||||
{icon?.withTitle && <Title headingLevel="h1" aria-hidden="true" />}
|
||||
<Title
|
||||
headingLevel="h1"
|
||||
className={icon?.withTitle ? undefined : 'sr-only'}
|
||||
/>
|
||||
</Box>
|
||||
</StyledLink>
|
||||
{!isDesktop ? (
|
||||
|
||||
@@ -4,14 +4,18 @@ import { Text } from '@/components/';
|
||||
|
||||
type TitleSemanticsProps = {
|
||||
headingLevel?: 'h1' | 'h2' | 'h3';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Title = ({ headingLevel = 'h2' }: TitleSemanticsProps) => {
|
||||
export const Title = ({
|
||||
headingLevel = 'h2',
|
||||
className,
|
||||
}: TitleSemanticsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Text
|
||||
className="--docs--title"
|
||||
className={`--docs--title${className ? ` ${className}` : ''}`}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$margin="none"
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Waffle = () => {
|
||||
<LaGaufreV2Fixed
|
||||
{...waffleConfig}
|
||||
label={waffleConfig.label ?? t('Digital LaSuite services')}
|
||||
newWindowLabelSuffix={t('new window')}
|
||||
newWindowLabelSuffix={` (${t('new window')})`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export const HelpMenu = ({
|
||||
>
|
||||
<Box $direction="row" $align="center">
|
||||
<Button
|
||||
aria-label={t('Open onboarding menu')}
|
||||
aria-label={t('Open help menu')}
|
||||
color={colorButton || 'neutral'}
|
||||
variant="tertiary"
|
||||
iconPosition="left"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRouter } from 'next/router';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, SeparatedSection } from '@/components';
|
||||
import { useDocStore } from '@/docs/doc-management';
|
||||
|
||||
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
|
||||
import { LeftPanelDocContent } from './LeftPanelDocContent';
|
||||
@@ -12,33 +11,35 @@ export const LeftPanelContent = () => {
|
||||
const router = useRouter();
|
||||
const isHome = router.pathname === '/';
|
||||
const isDoc = router.pathname === '/docs/[id]';
|
||||
const { currentDoc } = useDocStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isHome && (
|
||||
<>
|
||||
<Box
|
||||
$width="100%"
|
||||
$css={css`
|
||||
flex: 0 0 auto;
|
||||
`}
|
||||
className="--docs--home-left-panel-content"
|
||||
>
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<LeftPanelTargetFilters />
|
||||
</SeparatedSection>
|
||||
</Box>
|
||||
<Box
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$css="overflow-y: auto; overflow-x: hidden;"
|
||||
>
|
||||
<LeftPanelFavorites />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{isDoc && currentDoc && <LeftPanelDocContent doc={currentDoc} />}
|
||||
</>
|
||||
);
|
||||
if (isHome) {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
$width="100%"
|
||||
$css={css`
|
||||
flex: 0 0 auto;
|
||||
`}
|
||||
className="--docs--home-left-panel-content"
|
||||
>
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<LeftPanelTargetFilters />
|
||||
</SeparatedSection>
|
||||
</Box>
|
||||
<Box
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$css="overflow-y: auto; overflow-x: hidden;"
|
||||
>
|
||||
<LeftPanelFavorites />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDoc) {
|
||||
return <LeftPanelDocContent />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
+9
-7
@@ -1,15 +1,13 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { Doc, useDocStore } from '@/docs/doc-management';
|
||||
import { DocTree } from '@/docs/doc-tree/';
|
||||
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
|
||||
|
||||
export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
|
||||
export const LeftPanelDocContent = () => {
|
||||
const tree = useTreeContext<Doc>();
|
||||
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
const { currentDoc } = useDocStore();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -18,7 +16,11 @@ export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
|
||||
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
||||
className="--docs--left-panel-doc-content"
|
||||
>
|
||||
<DocTree currentDoc={doc} />
|
||||
{tree && currentDoc ? (
|
||||
<DocTree currentDoc={currentDoc} />
|
||||
) : (
|
||||
<TreeSkeleton />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,69 +1,12 @@
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
type SkeletonLineProps = Partial<BoxType>;
|
||||
|
||||
type SkeletonCircleProps = Partial<BoxType>;
|
||||
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
|
||||
|
||||
export const DocEditorSkeleton = () => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
$height="16px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['black-050']} 0%,
|
||||
${colorsTokens['black-100']} 50%,
|
||||
${colorsTokens['black-050']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 4px;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
|
||||
return (
|
||||
<Box
|
||||
$width="32px"
|
||||
$height="32px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['black-050']} 0%,
|
||||
${colorsTokens['black-100']} 50%,
|
||||
${colorsTokens['black-050']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 50%;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from '@/components/Box';
|
||||
import { useCunninghamTheme } from '@/cunningham/useCunninghamTheme';
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
type SkeletonLineProps = Partial<BoxType>;
|
||||
|
||||
type SkeletonCircleProps = Partial<BoxType>;
|
||||
|
||||
export const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
$height="16px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['black-050']} 0%,
|
||||
${colorsTokens['black-100']} 50%,
|
||||
${colorsTokens['black-050']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 4px;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$width="32px"
|
||||
$height="32px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['black-050']} 0%,
|
||||
${colorsTokens['black-100']} 50%,
|
||||
${colorsTokens['black-050']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 50%;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Box } from '@/components';
|
||||
|
||||
import { SkeletonLine } from './SkeletionUI';
|
||||
|
||||
export const TreeSkeleton = () => {
|
||||
return (
|
||||
<Box className="--docs--tree-skeleton">
|
||||
<SkeletonLine
|
||||
$width="92%"
|
||||
$height="40px"
|
||||
$margin={{ left: 'sm', top: 'sm' }}
|
||||
/>
|
||||
<SkeletonLine
|
||||
$width="92%"
|
||||
$height="30px"
|
||||
$margin={{ left: 'sm', top: 'sm' }}
|
||||
/>
|
||||
<SkeletonLine
|
||||
$width="92%"
|
||||
$height="30px"
|
||||
$margin={{ left: 'sm', top: 'sm' }}
|
||||
/>
|
||||
<SkeletonLine
|
||||
$width="92%"
|
||||
$height="30px"
|
||||
$margin={{ left: 'sm', top: 'sm' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { announce } from '@react-aria/live-announcer';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,22 +15,18 @@ export const useClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
toast(
|
||||
successMessage ?? t('Copied to clipboard'),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 3000,
|
||||
},
|
||||
);
|
||||
const message = successMessage ?? t('Copied to clipboard');
|
||||
toast(message, VariantType.SUCCESS, {
|
||||
duration: 3000,
|
||||
});
|
||||
announce(message, 'polite');
|
||||
})
|
||||
.catch(() => {
|
||||
toast(
|
||||
errorMessage ?? t('Failed to copy to clipboard'),
|
||||
VariantType.ERROR,
|
||||
{
|
||||
duration: 3000,
|
||||
},
|
||||
);
|
||||
const message = errorMessage ?? t('Failed to copy to clipboard');
|
||||
toast(message, VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
announce(message, 'assertive');
|
||||
});
|
||||
},
|
||||
[t, toast],
|
||||
|
||||
@@ -905,7 +905,7 @@
|
||||
"Open Source": "Open Source",
|
||||
"Open document {{title}}": "Ouvrir le document {{title}}",
|
||||
"Open document: {{title}}": "Ouvrir le document : {{title}}",
|
||||
"Open onboarding menu": "Ouvrir le menu d'embarquement",
|
||||
"Open help menu": "Ouvrir le menu d'aide",
|
||||
"Open root document": "Ouvrir le document racine",
|
||||
"Open the document options": "Ouvrir les options du document",
|
||||
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"private": true,
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-plugin-docs",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server-y-provider",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"description": "Y.js provider for docs",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 4.8.0
|
||||
- version: 4.8.2
|
||||
feature:
|
||||
values:
|
||||
- version: 4.8.0
|
||||
- version: 4.8.2
|
||||
feature: ci
|
||||
domain: example.com
|
||||
imageTag: demo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 4.8.1
|
||||
version: 4.8.2
|
||||
appVersion: latest
|
||||
|
||||
@@ -74,6 +74,20 @@ spec:
|
||||
serviceName: {{ include "impress.backend.fullname" . }}
|
||||
servicePort: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
- path: /external_api
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "impress.backend.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "impress.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
@@ -110,6 +124,20 @@ spec:
|
||||
serviceName: {{ include "impress.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
- path: /external_api
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "impress.backend.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "impress.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.ingress.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "4.8.1",
|
||||
"version": "4.8.2",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -537,7 +537,7 @@ leac@^0.6.0:
|
||||
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
|
||||
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
|
||||
|
||||
lodash@>=4.17.23, lodash@^4.17.21:
|
||||
lodash@^4.17.21:
|
||||
version "4.17.23"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
|
||||
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
|
||||
|
||||
Reference in New Issue
Block a user