diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c1ae7ac..d851101a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to ## [Unreleased] +### Added + +- 🔧(backend) settings CONVERSION_UPLOAD_ENABLED to control usage of docspec + ### Changed - ♿(frontend) use aria-haspopup menu on DropButton triggers #2126 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 630bfe709..ec77b1175 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -680,6 +680,11 @@ class DocumentViewSet( # Process it if present uploaded_file = serializer.validated_data.pop("file", None) + if uploaded_file and not settings.CONVERSION_UPLOAD_ENABLED: + raise drf.exceptions.ValidationError( + {"file": ["file upload is not allowed"]} + ) + # If a file is uploaded, convert it to Yjs format and set as content if uploaded_file: try: diff --git a/src/backend/core/tests/documents/test_api_documents_create_with_file.py b/src/backend/core/tests/documents/test_api_documents_create_with_file.py index 3cd6dda2e..185bc2620 100644 --- a/src/backend/core/tests/documents/test_api_documents_create_with_file.py +++ b/src/backend/core/tests/documents/test_api_documents_create_with_file.py @@ -40,7 +40,7 @@ def test_api_documents_create_with_file_anonymous(): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_docx_file_success(mock_convert): +def test_api_documents_create_with_docx_file_success(mock_convert, settings): """ Authenticated users should be able to create documents by uploading a DOCX file. The file should be converted to YJS format and the title should be set from filename. @@ -49,6 +49,8 @@ def test_api_documents_create_with_docx_file_success(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion converted_yjs = "base64encodedyjscontent" mock_convert.return_value = converted_yjs @@ -81,7 +83,38 @@ def test_api_documents_create_with_docx_file_success(mock_convert): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_markdown_file_success(mock_convert): +def test_api_documents_create_with_docx_file_disabled(mock_convert, settings): + """ + When conversion is not enabled, uploading a file should have no effect + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + settings.CONVERSION_UPLOAD_ENABLED = False + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "My Important Document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["file upload is not allowed"]} + + # Verify the converter was not called + mock_convert.assert_not_called() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_markdown_file_success(mock_convert, settings): """ Authenticated users should be able to create documents by uploading a Markdown file. """ @@ -89,6 +122,8 @@ def test_api_documents_create_with_markdown_file_success(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion converted_yjs = "base64encodedyjscontent" mock_convert.return_value = converted_yjs @@ -121,7 +156,7 @@ def test_api_documents_create_with_markdown_file_success(mock_convert): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_file_and_explicit_title(mock_convert): +def test_api_documents_create_with_file_and_explicit_title(mock_convert, settings): """ When both file and title are provided, the filename should override the title. """ @@ -129,6 +164,8 @@ def test_api_documents_create_with_file_and_explicit_title(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion converted_yjs = "base64encodedyjscontent" mock_convert.return_value = converted_yjs @@ -153,7 +190,7 @@ def test_api_documents_create_with_file_and_explicit_title(mock_convert): assert document.title == "Uploaded Document.docx" -def test_api_documents_create_with_empty_file(): +def test_api_documents_create_with_empty_file(settings): """ Creating a document with an empty file should fail with a validation error. """ @@ -161,6 +198,8 @@ def test_api_documents_create_with_empty_file(): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Create an empty file file = BytesIO(b"") file.name = "empty.docx" @@ -179,7 +218,7 @@ def test_api_documents_create_with_empty_file(): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_file_conversion_error(mock_convert): +def test_api_documents_create_with_file_conversion_error(mock_convert, settings): """ When conversion fails, the API should return a 400 error with appropriate message. """ @@ -187,6 +226,8 @@ def test_api_documents_create_with_file_conversion_error(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion to raise an error mock_convert.side_effect = ConversionError("Failed to convert document") @@ -209,7 +250,7 @@ def test_api_documents_create_with_file_conversion_error(mock_convert): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_file_service_unavailable(mock_convert): +def test_api_documents_create_with_file_service_unavailable(mock_convert, settings): """ When the conversion service is unavailable, appropriate error should be returned. """ @@ -217,6 +258,8 @@ def test_api_documents_create_with_file_service_unavailable(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion to raise ServiceUnavailableError mock_convert.side_effect = ServiceUnavailableError( "Failed to connect to conversion service" @@ -264,7 +307,7 @@ def test_api_documents_create_without_file_still_works(): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_file_null_value(mock_convert): +def test_api_documents_create_with_file_null_value(mock_convert, settings): """ Passing file=null should be treated as no file upload. """ @@ -272,6 +315,8 @@ def test_api_documents_create_with_file_null_value(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + response = client.post( "/api/v1.0/documents/", { @@ -289,7 +334,9 @@ def test_api_documents_create_with_file_null_value(mock_convert): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_file_preserves_content_format(mock_convert): +def test_api_documents_create_with_file_preserves_content_format( + mock_convert, settings +): """ Verify that the converted content is stored correctly in the document. """ @@ -297,6 +344,8 @@ def test_api_documents_create_with_file_preserves_content_format(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion with realistic base64-encoded YJS data converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA=" mock_convert.return_value = converted_yjs @@ -328,7 +377,7 @@ def test_api_documents_create_with_file_preserves_content_format(mock_convert): @patch("core.services.converter_services.Converter.convert") -def test_api_documents_create_with_file_unicode_filename(mock_convert): +def test_api_documents_create_with_file_unicode_filename(mock_convert, settings): """ Test that Unicode characters in filenames are handled correctly. """ @@ -336,6 +385,8 @@ def test_api_documents_create_with_file_unicode_filename(mock_convert): client = APIClient() client.force_login(user) + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion converted_yjs = "base64encodedyjscontent" mock_convert.return_value = converted_yjs @@ -363,6 +414,7 @@ def test_api_documents_create_with_file_max_size_exceeded(settings): The uploaded file should not exceed the maximum size in settings. """ settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test + settings.CONVERSION_UPLOAD_ENABLED = True user = factories.UserFactory() client = APIClient() @@ -389,6 +441,7 @@ def test_api_documents_create_with_file_extension_not_allowed(settings): The uploaded file should not have an allowed extension. """ settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"] + settings.CONVERSION_UPLOAD_ENABLED = True user = factories.UserFactory() client = APIClient() 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 index 373b30475..036a87c81 100644 --- a/src/backend/core/tests/external_api/test_external_api_documents.py +++ b/src/backend/core/tests/external_api/test_external_api_documents.py @@ -261,7 +261,7 @@ def test_external_api_documents_create_subdocument_reader_not_allowed( @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 + mock_convert, user_token, resource_server_backend, user_specific_sub, settings ): """ Users with an access token should be able to create documents through the resource @@ -272,6 +272,8 @@ def test_external_api_documents_create_with_markdown_file_success( client = APIClient() client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + settings.CONVERSION_UPLOAD_ENABLED = True + # Mock the conversion converted_yjs = "base64encodedyjscontent" mock_convert.return_value = converted_yjs diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 62e5a46ac..aea0b45d4 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -866,6 +866,9 @@ class Base(Configuration): DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None) # Imported file settings + CONVERSION_UPLOAD_ENABLED = values.BooleanValue( + False, environ_name="CONVERSION_UPLOAD_ENABLED", environ_prefix=None + ) CONVERSION_FILE_MAX_SIZE = values.IntegerValue( 20 * MB, environ_name="CONVERSION_FILE_MAX_SIZE",