mirror of
https://github.com/suitenumerique/docs
synced 2026-04-21 13:37:20 +00:00
✨(backend) add resource server api
Adds a resource server API similar to the one that already exists for Drive.
This commit is contained in:
parent
4f8aea7b80
commit
4e54a53072
26 changed files with 3577 additions and 19 deletions
|
|
@ -35,6 +35,7 @@ and this project adheres to
|
|||
|
||||
- ✨(backend) add a is_first_connection flag to the User model #1938
|
||||
- ✨(frontend) add onboarding modal with help menu button #1868
|
||||
- ✨(backend) add resource server api #1923
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
106
docs/resource_server.md
Normal file
106
docs/resource_server.md
Normal file
|
|
@ -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
|
||||
|
|
|
|||
41
src/backend/core/external_api/permissions.py
Normal file
41
src/backend/core/external_api/permissions.py
Normal file
|
|
@ -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
|
||||
)
|
||||
91
src/backend/core/external_api/viewsets.py
Normal file
91
src/backend/core/external_api/viewsets.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
0
src/backend/core/tests/external_api/__init__.py
Normal file
0
src/backend/core/tests/external_api/__init__.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
158
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
158
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
20
src/backend/core/tests/utils/urls.py
Normal file
20
src/backend/core/tests/utils/urls.py
Normal file
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Reference in a new issue