From 4e54a530728c06ac57e3185eddfff9d2852c197b Mon Sep 17 00:00:00 2001 From: Sylvain Boissel Date: Thu, 26 Feb 2026 12:39:14 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20resource=20server=20?= =?UTF-8?q?api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a resource server API similar to the one that already exists for Drive. --- CHANGELOG.md | 1 + Makefile | 5 + .../etc/nginx/conf.d/default.conf.template | 4 + docs/resource_server.md | 106 +++ env.d/development/common | 9 + src/backend/core/api/viewsets.py | 35 +- src/backend/core/external_api/permissions.py | 41 + src/backend/core/external_api/viewsets.py | 91 +++ src/backend/core/tests/conftest.py | 94 +++ .../core/tests/external_api/__init__.py | 0 .../test_external_api_documents.py | 772 ++++++++++++++++++ .../test_external_api_documents_accesses.py | 681 +++++++++++++++ .../test_external_api_documents_ai.py | 273 +++++++ ...xternal_api_documents_attachment_upload.py | 121 +++ .../test_external_api_documents_favorite.py | 157 ++++ .../test_external_api_documents_invitation.py | 474 +++++++++++ ...ternal_api_documents_link_configuration.py | 105 +++ .../test_external_api_documents_media_auth.py | 94 +++ .../test_external_api_documents_versions.py | 163 ++++ .../external_api/test_external_api_users.py | 158 ++++ src/backend/core/tests/test_api_users.py | 10 +- src/backend/core/tests/utils/urls.py | 20 + src/backend/core/urls.py | 49 ++ src/backend/impress/settings.py | 103 +++ src/helm/impress/templates/ingress.yaml | 28 + src/mail/yarn.lock | 2 +- 26 files changed, 3577 insertions(+), 19 deletions(-) create mode 100644 docs/resource_server.md create mode 100644 src/backend/core/external_api/permissions.py create mode 100644 src/backend/core/external_api/viewsets.py create mode 100644 src/backend/core/tests/external_api/__init__.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_accesses.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_ai.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_attachment_upload.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_favorite.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_invitation.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_media_auth.py create mode 100644 src/backend/core/tests/external_api/test_external_api_documents_versions.py create mode 100644 src/backend/core/tests/external_api/test_external_api_users.py create mode 100644 src/backend/core/tests/utils/urls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aede6fb6..d3287593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 2f7c18fc..fd1d10b0 100644 --- a/Makefile +++ b/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 diff --git a/docker/files/production/etc/nginx/conf.d/default.conf.template b/docker/files/production/etc/nginx/conf.d/default.conf.template index d1c4a8e3..6fff9c52 100644 --- a/docker/files/production/etc/nginx/conf.d/default.conf.template +++ b/docker/files/production/etc/nginx/conf.d/default.conf.template @@ -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; } diff --git a/docs/resource_server.md b/docs/resource_server.md new file mode 100644 index 00000000..d2d35311 --- /dev/null +++ b/docs/resource_server.md @@ -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"}, +) +``` diff --git a/env.d/development/common b/env.d/development/common index bd63511d..7ea51a34 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -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. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7e7a2f18..d402c91b 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -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//ai-proxy ### Ordering: created_at, updated_at, is_favorite, title diff --git a/src/backend/core/external_api/permissions.py b/src/backend/core/external_api/permissions.py new file mode 100644 index 00000000..b0114886 --- /dev/null +++ b/src/backend/core/external_api/permissions.py @@ -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 + ) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py new file mode 100644 index 00000000..9a8bafcb --- /dev/null +++ b/src/backend/core/external_api/viewsets.py @@ -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") diff --git a/src/backend/core/tests/conftest.py b/src/backend/core/tests/conftest.py index a342499d..0af57d9f 100644 --- a/src/backend/core/tests/conftest.py +++ b/src/backend/core/tests/conftest.py @@ -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") diff --git a/src/backend/core/tests/external_api/__init__.py b/src/backend/core/tests/external_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/external_api/test_external_api_documents.py b/src/backend/core/tests/external_api/test_external_api_documents.py new file mode 100644 index 00000000..373b3047 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents.py @@ -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 diff --git a/src/backend/core/tests/external_api/test_external_api_documents_accesses.py b/src/backend/core/tests/external_api/test_external_api_documents_accesses.py new file mode 100644 index 00000000..957b3082 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_accesses.py @@ -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 diff --git a/src/backend/core/tests/external_api/test_external_api_documents_ai.py b/src/backend/core/tests/external_api/test_external_api_documents_ai.py new file mode 100644 index 00000000..848be4f9 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_ai.py @@ -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() diff --git a/src/backend/core/tests/external_api/test_external_api_documents_attachment_upload.py b/src/backend/core/tests/external_api/test_external_api_documents_attachment_upload.py new file mode 100644 index 00000000..d4a68801 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_attachment_upload.py @@ -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) diff --git a/src/backend/core/tests/external_api/test_external_api_documents_favorite.py b/src/backend/core/tests/external_api/test_external_api_documents_favorite.py new file mode 100644 index 00000000..86883a9d --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_favorite.py @@ -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() diff --git a/src/backend/core/tests/external_api/test_external_api_documents_invitation.py b/src/backend/core/tests/external_api/test_external_api_documents_invitation.py new file mode 100644 index 00000000..357850f8 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_invitation.py @@ -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 diff --git a/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py b/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py new file mode 100644 index 00000000..9fc3d8dd --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_link_configuration.py @@ -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 diff --git a/src/backend/core/tests/external_api/test_external_api_documents_media_auth.py b/src/backend/core/tests/external_api/test_external_api_documents_media_auth.py new file mode 100644 index 00000000..01129507 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_media_auth.py @@ -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 diff --git a/src/backend/core/tests/external_api/test_external_api_documents_versions.py b/src/backend/core/tests/external_api/test_external_api_documents_versions.py new file mode 100644 index 00000000..5c460bd3 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_documents_versions.py @@ -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" diff --git a/src/backend/core/tests/external_api/test_external_api_users.py b/src/backend/core/tests/external_api/test_external_api_users.py new file mode 100644 index 00000000..ffdc1d67 --- /dev/null +++ b/src/backend/core/tests/external_api/test_external_api_users.py @@ -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 diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 165e3680..8c0b3fdd 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -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) diff --git a/src/backend/core/tests/utils/urls.py b/src/backend/core/tests/utils/urls.py new file mode 100644 index 00000000..78455de1 --- /dev/null +++ b/src/backend/core/tests/utils/urls.py @@ -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() diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index d88582d2..cf4de465 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -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[0-9a-z-]*)/", + include(external_api_document_related_router.urls), + ), + ] + ), + ) + ) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 800ecb3c..62e5a46a 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -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 ) diff --git a/src/helm/impress/templates/ingress.yaml b/src/helm/impress/templates/ingress.yaml index ff0528ae..71baf54c 100644 --- a/src/helm/impress/templates/ingress.yaml +++ b/src/helm/impress/templates/ingress.yaml @@ -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 }} diff --git a/src/mail/yarn.lock b/src/mail/yarn.lock index 970c2394..1b98d620 100644 --- a/src/mail/yarn.lock +++ b/src/mail/yarn.lock @@ -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==