"""Tests for file helper functionality in composio/core/models/_files.py. These tests ensure that the FileHelper class correctly handles JSON schemas that use anyOf, oneOf, allOf, or $ref instead of direct 'type' properties. """ import tempfile from pathlib import Path from unittest.mock import Mock, patch, MagicMock import pytest import requests from composio.client.types import Tool, tool_list_response from composio.core.models._files import ( FileDownloadable, FileHelper, FileUploadable, _is_url, _get_extension_from_mimetype, _generate_timestamped_filename, _truncate_filename, _fetch_file_from_url, _upload_bytes_to_s3, _sanitize_url_for_logging, _MAX_FILENAME_LENGTH, ) from composio.core.models.base import allow_tracking from composio.exceptions import ( ErrorDownloadingFile, ErrorUploadingFile, ResponseTooLargeError, SensitiveFilePathBlockedError, ) @pytest.fixture(autouse=True) def disable_telemetry(): """Disable telemetry for all tests to prevent thread issues.""" token = allow_tracking.set(False) yield allow_tracking.reset(token) @pytest.fixture def mock_client(): """Create a mock HTTP client.""" return Mock() @pytest.fixture def file_helper(mock_client): """Create a FileHelper instance with a mock client.""" return FileHelper(client=mock_client) @pytest.fixture def mock_tool(): """Create a mock tool for testing.""" return Tool( name="Test Tool", slug="TEST_TOOL", description="Test tool", input_parameters={ "properties": { "query": {"type": "string"}, }, "type": "object", }, output_parameters={ "properties": { "data": {"type": "object", "properties": {}}, }, "type": "object", }, available_versions=["v1.0.0"], version="v1.0.0", scopes=[], toolkit=tool_list_response.ItemToolkit( name="Test Toolkit", slug="test_toolkit", logo="" ), deprecated=tool_list_response.ItemDeprecated( available_versions=["v1.0.0"], displayName="Test Tool", version="v1.0.0", toolkit=tool_list_response.ItemDeprecatedToolkit(logo=""), is_deprecated=False, ), is_deprecated=False, no_auth=False, tags=[], ) class TestFileHelperSchemaHandling: """Test cases for handling schemas without direct 'type' property. Regression tests for PLEN-766: KeyError: 'type' when schemas use anyOf, oneOf, allOf, or $ref instead of direct type properties. """ def test_substitute_file_uploads_with_oneof_schema(self, file_helper, mock_tool): """Test that oneOf schemas don't cause KeyError in upload path.""" schema_with_oneof = { "properties": { "input": { "oneOf": [ {"type": "string"}, {"type": "object", "properties": {"file": {"type": "string"}}}, ] } } } request = {"input": {"file": "test.txt"}} # Should not raise KeyError result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_with_oneof, request=request.copy(), ) assert result == {"input": {"file": "test.txt"}} def test_substitute_file_uploads_with_anyof_schema(self, file_helper, mock_tool): """Test that anyOf schemas don't cause KeyError in upload path.""" schema_with_anyof = { "properties": { "data": { "anyOf": [ {"type": "string"}, {"type": "object", "properties": {"value": {"type": "string"}}}, ] } } } request = {"data": {"value": "test"}} # Should not raise KeyError result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_with_anyof, request=request.copy(), ) assert result == {"data": {"value": "test"}} def test_substitute_file_uploads_with_allof_schema(self, file_helper, mock_tool): """Test that allOf schemas don't cause KeyError in upload path.""" schema_with_allof = { "properties": { "config": { "allOf": [ {"properties": {"name": {"type": "string"}}}, {"properties": {"value": {"type": "string"}}}, ] } } } request = {"config": {"name": "test", "value": "123"}} # Should not raise KeyError result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_with_allof, request=request.copy(), ) assert result == {"config": {"name": "test", "value": "123"}} def test_substitute_file_uploads_with_ref_schema(self, file_helper, mock_tool): """Test that $ref schemas don't cause KeyError in upload path.""" schema_with_ref = { "properties": {"reference": {"$ref": "#/definitions/SomeType"}} } request = {"reference": {"nested": "value"}} # Should not raise KeyError result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_with_ref, request=request.copy(), ) assert result == {"reference": {"nested": "value"}} def test_substitute_file_downloads_with_oneof_schema(self, file_helper, mock_tool): """Test that oneOf schemas don't cause KeyError in download path.""" schema_with_oneof = { "properties": { "result": { "oneOf": [ {"type": "string"}, {"type": "object", "properties": {"data": {"type": "string"}}}, ] } } } response = {"result": {"data": "some value"}} # Should not raise KeyError result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=schema_with_oneof, request=response.copy(), ) assert result == {"result": {"data": "some value"}} def test_substitute_file_downloads_with_anyof_schema(self, file_helper, mock_tool): """Test that anyOf schemas don't cause KeyError in download path.""" schema_with_anyof = { "properties": { "output": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"nested": {"type": "string"}}, }, ] } } } response = {"output": {"nested": "value"}} # Should not raise KeyError result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=schema_with_anyof, request=response.copy(), ) assert result == {"output": {"nested": "value"}} def test_substitute_file_downloads_with_allof_schema(self, file_helper, mock_tool): """Test that allOf schemas don't cause KeyError in download path.""" schema_with_allof = { "properties": { "response": { "allOf": [ {"properties": {"status": {"type": "string"}}}, {"properties": {"message": {"type": "string"}}}, ] } } } response = {"response": {"status": "ok", "message": "success"}} # Should not raise KeyError result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=schema_with_allof, request=response.copy(), ) assert result == {"response": {"status": "ok", "message": "success"}} def test_substitute_file_downloads_with_ref_schema(self, file_helper, mock_tool): """Test that $ref schemas don't cause KeyError in download path.""" schema_with_ref = { "properties": {"data": {"$ref": "#/definitions/ResponseType"}} } response = {"data": {"field": "value"}} # Should not raise KeyError result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=schema_with_ref, request=response.copy(), ) assert result == {"data": {"field": "value"}} def test_substitute_file_uploads_with_normal_type_still_works( self, file_helper, mock_tool ): """Test that normal schemas with 'type' property still work correctly.""" schema_with_type = { "properties": { "nested": { "type": "object", "properties": { "value": {"type": "string"}, }, } } } request = {"nested": {"value": "test"}} # Should process normally and recurse into nested object result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_with_type, request=request.copy(), ) assert result == {"nested": {"value": "test"}} def test_substitute_file_downloads_with_normal_type_still_works( self, file_helper, mock_tool ): """Test that normal schemas with 'type' property still work correctly.""" schema_with_type = { "properties": { "data": { "type": "object", "properties": { "result": {"type": "string"}, }, } } } response = {"data": {"result": "success"}} # Should process normally and recurse into nested object result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=schema_with_type, request=response.copy(), ) assert result == {"data": {"result": "success"}} def test_substitute_with_empty_properties(self, file_helper, mock_tool): """Test handling of schemas with empty properties.""" schema_empty = {"properties": {}} request = {"unknown": {"nested": "value"}} # Should not raise any errors result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_empty, request=request.copy(), ) assert result == {"unknown": {"nested": "value"}} def test_substitute_with_no_properties_key(self, file_helper, mock_tool): """Test handling of schemas without properties key.""" schema_no_props = {"type": "object"} request = {"data": {"value": "test"}} # Should return request unchanged result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=schema_no_props, request=request.copy(), ) assert result == {"data": {"value": "test"}} class TestFileHelperRefDefsResolution: """File flags reachable only through a $ref/$defs indirection. Regression coverage for https://github.com/ComposioHQ/composio/issues/3506. References are inlined once at the public-method boundary (``dereference_json_schema``), so the walkers themselves stay reference-agnostic. """ @patch("composio.core.models._files.FileDownloadable.download") def test_download_resolves_ref_defs(self, mock_download, file_helper, mock_tool): """GMAIL_GET_ATTACHMENT shape: file_downloadable two $ref hops deep.""" mock_download.return_value = "/tmp/invoice.pdf" mock_tool.output_parameters = { "type": "object", "$defs": { "FileDownloadable": { "type": "object", "properties": { "name": {"type": "string"}, "mimetype": {"type": "string"}, "s3url": {"type": "string"}, }, "required": ["name", "mimetype", "s3url"], "file_downloadable": True, }, "GetAttachmentResponse": { "type": "object", "properties": { "file": {"$ref": "#/$defs/FileDownloadable"}, "display_url": {"type": "string"}, }, "required": ["file"], }, }, "properties": { "data": {"$ref": "#/$defs/GetAttachmentResponse"}, "successful": {"type": "boolean"}, }, "required": ["data", "successful"], } response = { "data": { "file": { "name": "invoice.pdf", "mimetype": "application/pdf", "s3url": "https://s3.example.com/invoice.pdf", }, "display_url": "https://mail.google.com/...", }, "successful": True, } result = file_helper.substitute_file_downloads( tool=mock_tool, response=response, ) assert result["data"]["file"] == "/tmp/invoice.pdf" assert result["data"]["display_url"] == "https://mail.google.com/..." assert result["successful"] is True mock_download.assert_called_once() @patch("composio.core.models._files.FileUploadable.from_path") def test_upload_resolves_ref_defs(self, mock_from_path, file_helper, mock_tool): """$ref/$defs input schema exposes a nested file_uploadable leaf.""" upload = MagicMock() upload.model_dump.return_value = { "name": "invoice.pdf", "mimetype": "application/pdf", "s3key": "uploads/invoice.pdf", } mock_from_path.return_value = upload mock_tool.input_parameters = { "type": "object", "$defs": { "FileUploadable": {"type": "string", "file_uploadable": True}, "AttachmentInput": { "type": "object", "properties": { "file": {"$ref": "#/$defs/FileUploadable"}, "name": {"type": "string"}, }, "required": ["file"], }, }, "properties": {"attachment": {"$ref": "#/$defs/AttachmentInput"}}, "required": ["attachment"], } request = {"attachment": {"file": "/local/invoice.pdf", "name": "invoice.pdf"}} result = file_helper.substitute_file_uploads( tool=mock_tool, request=request, ) assert result["attachment"]["file"] == { "name": "invoice.pdf", "mimetype": "application/pdf", "s3key": "uploads/invoice.pdf", } assert result["attachment"]["name"] == "invoice.pdf" mock_from_path.assert_called_once() def test_process_file_uploadable_schema_resolves_ref_defs(self, file_helper): """The LLM-facing input transform inlines $ref and rewrites the leaf.""" schema = { "type": "object", "$defs": { "FileUploadable": {"type": "string", "file_uploadable": True}, "AttachmentInput": { "type": "object", "properties": {"file": {"$ref": "#/$defs/FileUploadable"}}, }, }, "properties": {"attachment": {"$ref": "#/$defs/AttachmentInput"}}, } result = file_helper.process_file_uploadable_schema(schema) leaf = result["properties"]["attachment"]["properties"]["file"] assert leaf["type"] == "string" assert leaf["format"] == "path" assert leaf["file_uploadable"] is True # $defs is inlined away; no dangling references remain. assert "$defs" not in result assert "$ref" not in result["properties"]["attachment"] @patch("composio.core.models._files.FileDownloadable.download") def test_dangling_ref_download_degrades_gracefully( self, mock_download, file_helper, mock_tool ): """A $ref with no $defs block (issue #3307) must not raise (sentinel).""" mock_tool.output_parameters = { "type": "object", "properties": { "data": {"$ref": "#/$defs/Missing"}, "successful": {"type": "boolean"}, }, } response = {"data": {"value": "passthrough"}, "successful": True} # No $defs block at all: strict resolution would raise and abort the # tool call. Sentinel mode keeps execution working — the unresolved # branch is simply not treated as a file. result = file_helper.substitute_file_downloads( tool=mock_tool, response=response, ) assert result == {"data": {"value": "passthrough"}, "successful": True} mock_download.assert_not_called() @patch("composio.core.models._files.FileDownloadable.download") def test_recursive_ref_schema_does_not_recurse_infinitely( self, mock_download, file_helper, mock_tool ): """A self-referential $ref schema must not blow the stack. A per-node lazy resolver without threaded cycle tracking infinite-loops here; the centralized walker breaks the cycle with a sentinel. """ mock_download.return_value = "/tmp/node.bin" mock_tool.output_parameters = { "type": "object", "$defs": { "Node": { "type": "object", "properties": { "file": { "type": "object", "properties": {"s3url": {"type": "string"}}, "file_downloadable": True, }, "child": {"$ref": "#/$defs/Node"}, }, }, }, "properties": {"root": {"$ref": "#/$defs/Node"}}, } response = { "root": { "file": { "name": "n.bin", "mimetype": "application/octet-stream", "s3url": "https://s3/x", }, "child": {"value": "leaf"}, } } # Must complete without RecursionError. result = file_helper.substitute_file_downloads( tool=mock_tool, response=response, ) assert result["root"]["file"] == "/tmp/node.bin" assert result["root"]["child"] == {"value": "leaf"} class TestFileUploadableInUnionTypes: """Test cases for file_uploadable detection in anyOf, oneOf, and allOf schemas.""" def test_has_file_property_direct(self, file_helper): """Test _has_file_property detects direct file_uploadable.""" schema = {"type": "string", "file_uploadable": True} assert file_helper._has_file_property(schema, "file_uploadable") is True def test_has_file_property_in_anyof(self, file_helper): """Test _has_file_property detects file_uploadable in anyOf.""" schema = { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] } assert file_helper._has_file_property(schema, "file_uploadable") is True def test_has_file_property_in_oneof(self, file_helper): """Test _has_file_property detects file_uploadable in oneOf.""" schema = { "oneOf": [ {"type": "string", "file_uploadable": True}, {"type": "string", "description": "URL"}, ] } assert file_helper._has_file_property(schema, "file_uploadable") is True def test_has_file_property_in_allof(self, file_helper): """Test _has_file_property detects file_uploadable in allOf.""" schema = { "allOf": [ {"type": "string", "file_uploadable": True}, {"minLength": 1}, ] } assert file_helper._has_file_property(schema, "file_uploadable") is True def test_has_file_property_nested_in_anyof(self, file_helper): """Test _has_file_property detects file_uploadable nested in anyOf object.""" schema = { "anyOf": [ { "type": "object", "properties": { "attachment": {"type": "string", "file_uploadable": True} }, }, {"type": "string"}, ] } assert file_helper._has_file_property(schema, "file_uploadable") is True def test_has_file_property_not_present(self, file_helper): """Test _has_file_property returns False when not present.""" schema = { "anyOf": [ {"type": "string"}, {"type": "null"}, ] } assert file_helper._has_file_property(schema, "file_uploadable") is False def test_has_file_downloadable_in_anyof(self, file_helper): """Test _has_file_property detects file_downloadable in anyOf.""" schema = { "anyOf": [ {"type": "object", "file_downloadable": True}, {"type": "null"}, ] } assert file_helper._has_file_property(schema, "file_downloadable") is True def test_transform_schema_direct_file_uploadable(self, file_helper): """Test _transform_schema_for_file_upload transforms direct file_uploadable.""" schema = { "type": "string", "file_uploadable": True, "description": "Upload a file", } result = file_helper._transform_schema_for_file_upload(schema) assert result["format"] == "path" assert result["type"] == "string" assert result["file_uploadable"] is True def test_transform_schema_file_uploadable_in_anyof(self, file_helper): """Test _transform_schema_for_file_upload transforms file_uploadable in anyOf.""" schema = { "anyOf": [ {"type": "string", "file_uploadable": True, "description": "File path"}, {"type": "null"}, ] } result = file_helper._transform_schema_for_file_upload(schema) assert result["anyOf"][0]["format"] == "path" assert result["anyOf"][0]["file_uploadable"] is True assert "format" not in result["anyOf"][1] def test_transform_schema_file_uploadable_in_oneof(self, file_helper): """Test _transform_schema_for_file_upload transforms file_uploadable in oneOf.""" schema = { "oneOf": [ {"type": "string", "file_uploadable": True}, {"type": "string", "description": "URL reference"}, ] } result = file_helper._transform_schema_for_file_upload(schema) assert result["oneOf"][0]["format"] == "path" assert result["oneOf"][0]["file_uploadable"] is True assert "format" not in result["oneOf"][1] def test_transform_schema_file_uploadable_in_allof(self, file_helper): """Test _transform_schema_for_file_upload transforms file_uploadable in allOf.""" schema = { "allOf": [ {"type": "string", "file_uploadable": True}, {"minLength": 1}, ] } result = file_helper._transform_schema_for_file_upload(schema) assert result["allOf"][0]["format"] == "path" assert result["allOf"][0]["file_uploadable"] is True def test_transform_schema_nested_file_uploadable_in_anyof(self, file_helper): """Test transform handles nested file_uploadable inside anyOf objects.""" schema = { "anyOf": [ { "type": "object", "properties": { "attachment": {"type": "string", "file_uploadable": True} }, }, {"type": "string"}, ] } result = file_helper._transform_schema_for_file_upload(schema) assert result["anyOf"][0]["properties"]["attachment"]["format"] == "path" assert result["anyOf"][0]["properties"]["attachment"]["file_uploadable"] is True def test_transform_schema_array_items_with_anyof(self, file_helper): """Test transform handles array items with anyOf containing file_uploadable.""" schema = { "type": "array", "items": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] }, } result = file_helper._transform_schema_for_file_upload(schema) assert result["items"]["anyOf"][0]["format"] == "path" assert result["items"]["anyOf"][0]["file_uploadable"] is True def test_process_file_uploadable_schema_with_anyof(self, file_helper): """Test process_file_uploadable_schema handles anyOf properties.""" schema = { "type": "object", "properties": { "fileInput": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] }, "text": {"type": "string"}, }, } result = file_helper.process_file_uploadable_schema(schema) assert result["properties"]["fileInput"]["anyOf"][0]["format"] == "path" assert "format" not in result["properties"]["text"] class TestFileUploadSubstitutionWithUnionTypes: """Test cases for file upload substitution with anyOf, oneOf, and allOf schemas.""" def test_substitute_upload_with_file_uploadable_in_anyof( self, file_helper, mock_tool, mock_client ): """Test that file_uploadable in anyOf triggers file upload.""" mock_tool.input_parameters = { "type": "object", "properties": { "fileInput": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] } }, } # Mock the file upload mock_client.post.return_value = Mock( key="s3key/file.txt", new_presigned_url="https://s3.example.com/upload" ) with ( pytest.raises(Exception), ): # Will fail because file doesn't exist, but proves detection works file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"fileInput": "/path/to/file.txt"}, ) def test_substitute_upload_null_value_in_anyof(self, file_helper, mock_tool): """Test that null values in anyOf with file_uploadable are handled.""" mock_tool.input_parameters = { "type": "object", "properties": { "fileInput": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] } }, } result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"fileInput": None}, ) # None/empty values should be removed assert "fileInput" not in result def test_substitute_upload_empty_string_in_anyof(self, file_helper, mock_tool): """Test that empty string values in anyOf with file_uploadable are handled.""" mock_tool.input_parameters = { "type": "object", "properties": { "fileInput": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] } }, } result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"fileInput": ""}, ) # Empty values should be removed assert "fileInput" not in result def test_substitute_upload_nested_in_anyof_object(self, file_helper, mock_tool): """Test file upload for nested file_uploadable inside anyOf object.""" mock_tool.input_parameters = { "type": "object", "properties": { "content": { "anyOf": [ { "type": "object", "properties": { "attachment": { "type": "string", "file_uploadable": True, } }, }, {"type": "string"}, ] } }, } # Should try to process the nested file_uploadable with pytest.raises(Exception): # Will fail because file doesn't exist file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"content": {"attachment": "/path/to/file.pdf"}}, ) @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_upload_anyof_single_or_array_prefers_array_for_list_value( self, mock_from_path, file_helper, mock_tool ): """List runtime values should use the array branch and upload each item.""" mock_tool.input_parameters = { "type": "object", "properties": { "attachment": { "anyOf": [ {"type": "string", "file_uploadable": True}, { "type": "array", "items": {"type": "string", "file_uploadable": True}, }, ] } }, } upload_1 = MagicMock() upload_1.model_dump.return_value = { "name": "a.txt", "mimetype": "text/plain", "s3key": "key-a", } upload_2 = MagicMock() upload_2.model_dump.return_value = { "name": "b.txt", "mimetype": "text/plain", "s3key": "key-b", } mock_from_path.side_effect = [upload_1, upload_2] request = {"attachment": ["/tmp/a.txt", "/tmp/b.txt"]} result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request=request, ) assert result is request assert result["attachment"] == [ {"name": "a.txt", "mimetype": "text/plain", "s3key": "key-a"}, {"name": "b.txt", "mimetype": "text/plain", "s3key": "key-b"}, ] assert [call.kwargs["file"] for call in mock_from_path.call_args_list] == [ "/tmp/a.txt", "/tmp/b.txt", ] @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_upload_anyof_single_or_array_keeps_string_behavior( self, mock_from_path, file_helper, mock_tool ): """String runtime values should still use the single-file branch.""" mock_tool.input_parameters = { "type": "object", "properties": { "attachment": { "anyOf": [ {"type": "string", "file_uploadable": True}, { "type": "array", "items": {"type": "string", "file_uploadable": True}, }, ] } }, } upload = MagicMock() upload.model_dump.return_value = { "name": "a.txt", "mimetype": "text/plain", "s3key": "key-a", } mock_from_path.return_value = upload result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"attachment": "/tmp/a.txt"}, ) assert result["attachment"] == { "name": "a.txt", "mimetype": "text/plain", "s3key": "key-a", } mock_from_path.assert_called_once() assert mock_from_path.call_args.kwargs["file"] == "/tmp/a.txt" @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_upload_array_items_with_anyof_file_branch( self, mock_from_path, file_helper, mock_tool ): """Array item schemas can contain file-uploadable union branches.""" mock_tool.input_parameters = { "type": "object", "properties": { "attachments": { "type": "array", "items": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] }, } }, } upload_1 = MagicMock() upload_1.model_dump.return_value = {"s3key": "key-a"} upload_2 = MagicMock() upload_2.model_dump.return_value = {"s3key": "key-b"} mock_from_path.side_effect = [upload_1, upload_2] result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"attachments": ["/tmp/a.txt", None, "", "/tmp/b.txt"]}, ) assert result["attachments"] == [{"s3key": "key-a"}, {"s3key": "key-b"}] assert [call.kwargs["file"] for call in mock_from_path.call_args_list] == [ "/tmp/a.txt", "/tmp/b.txt", ] @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_upload_array_drops_all_null_and_empty_items( self, mock_from_path, file_helper, mock_tool ): """Array of file-uploadable items collapses to [] when every item is null/empty.""" mock_tool.input_parameters = { "type": "object", "properties": { "attachments": { "type": "array", "items": {"type": "string", "file_uploadable": True}, } }, } result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"attachments": [None, "", None]}, ) assert result["attachments"] == [] mock_from_path.assert_not_called() class TestFileDownloadSubstitutionWithUnionTypes: """Test cases for file download substitution with anyOf, oneOf, and allOf schemas.""" def test_substitute_download_with_file_downloadable_in_anyof( self, file_helper, mock_tool ): """Test that file_downloadable in anyOf triggers file download detection.""" mock_tool.output_parameters = { "type": "object", "properties": { "fileOutput": { "anyOf": [ { "type": "object", "file_downloadable": True, "properties": { "s3url": {"type": "string"}, "mimetype": {"type": "string"}, "name": {"type": "string"}, }, }, {"type": "null"}, ] } }, } # Should try to download the file (will fail but proves detection) with pytest.raises(Exception): file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request={ "fileOutput": { "s3url": "https://s3.example.com/file.txt", "mimetype": "text/plain", "name": "file.txt", } }, ) def test_substitute_download_null_value_in_anyof(self, file_helper, mock_tool): """Test that null values in anyOf with file_downloadable are handled.""" mock_tool.output_parameters = { "type": "object", "properties": { "fileOutput": { "anyOf": [ {"type": "object", "file_downloadable": True}, {"type": "null"}, ] } }, } result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request={"fileOutput": None}, ) assert result["fileOutput"] is None def test_substitute_download_nested_in_anyof_object(self, file_helper, mock_tool): """Test file download for nested file_downloadable inside anyOf object.""" mock_tool.output_parameters = { "type": "object", "properties": { "response": { "anyOf": [ { "type": "object", "properties": { "attachment": { "type": "object", "file_downloadable": True, "properties": { "s3url": {"type": "string"}, "mimetype": {"type": "string"}, "name": {"type": "string"}, }, } }, }, {"type": "string"}, ] } }, } # Should try to download the nested file with pytest.raises(Exception): file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request={ "response": { "attachment": { "s3url": "https://s3.example.com/doc.pdf", "mimetype": "application/pdf", "name": "document.pdf", } } }, ) def test_substitute_download_with_file_downloadable_in_oneof( self, file_helper, mock_tool ): """Test that file_downloadable in oneOf triggers file download detection.""" mock_tool.output_parameters = { "type": "object", "properties": { "result": { "oneOf": [ { "type": "object", "file_downloadable": True, "properties": { "s3url": {"type": "string"}, "mimetype": {"type": "string"}, "name": {"type": "string"}, }, }, {"type": "string"}, ] } }, } with pytest.raises(Exception): file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request={ "result": { "s3url": "https://s3.example.com/file.txt", "mimetype": "text/plain", "name": "file.txt", } }, ) def test_substitute_download_with_file_downloadable_in_allof( self, file_helper, mock_tool ): """Test that file_downloadable in allOf triggers file download detection.""" mock_tool.output_parameters = { "type": "object", "properties": { "image": { "allOf": [ { "type": "object", "file_downloadable": True, "properties": { "s3url": {"type": "string"}, "mimetype": {"type": "string"}, "name": {"type": "string"}, }, }, {"required": ["s3url"]}, ] } }, } with pytest.raises(Exception): file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request={ "image": { "s3url": "https://s3.example.com/image.png", "mimetype": "image/png", "name": "image.png", } }, ) @patch("composio.core.models._files.FileDownloadable.download") def test_substitute_download_anyof_single_or_array_prefers_array_for_list_value( self, mock_download, file_helper, mock_tool ): """List runtime values should use the array branch and download each item.""" mock_tool.output_parameters = { "type": "object", "properties": { "attachment": { "anyOf": [ {"type": "object", "file_downloadable": True}, { "type": "array", "items": {"type": "object", "file_downloadable": True}, }, ] } }, } mock_download.side_effect = ["/tmp/a.txt", "/tmp/b.txt"] request = { "attachment": [ { "s3url": "https://s3.example.com/a.txt", "mimetype": "text/plain", "name": "a.txt", }, { "s3url": "https://s3.example.com/b.txt", "mimetype": "text/plain", "name": "b.txt", }, ] } result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request=request, ) assert result is request assert result["attachment"] == ["/tmp/a.txt", "/tmp/b.txt"] assert mock_download.call_count == 2 @patch("composio.core.models._files.FileDownloadable.download") def test_substitute_download_array_items_with_anyof_file_branch( self, mock_download, file_helper, mock_tool ): """Array item schemas can contain file-downloadable union branches.""" mock_tool.output_parameters = { "type": "object", "properties": { "attachments": { "type": "array", "items": { "anyOf": [ {"type": "object", "file_downloadable": True}, {"type": "null"}, ] }, } }, } mock_download.side_effect = ["/tmp/a.txt", "/tmp/b.txt"] result = file_helper._substitute_file_downloads_recursively( tool=mock_tool, schema=mock_tool.output_parameters, request={ "attachments": [ { "s3url": "https://s3.example.com/a.txt", "mimetype": "text/plain", "name": "a.txt", }, None, { "s3url": "https://s3.example.com/b.txt", "mimetype": "text/plain", "name": "b.txt", }, ] }, ) assert result["attachments"] == ["/tmp/a.txt", None, "/tmp/b.txt"] assert mock_download.call_count == 2 class TestFileHelperFindVariantMethods: """Test cases for _find_uploadable_schema_variant and _find_downloadable_schema_variant.""" def test_find_uploadable_variant_in_anyof(self, file_helper): """Test finding uploadable variant in anyOf.""" schema = { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] } result = file_helper._find_uploadable_schema_variant(schema) assert result is not None assert result.get("file_uploadable") is True def test_find_uploadable_variant_in_oneof(self, file_helper): """Test finding uploadable variant in oneOf.""" schema = { "oneOf": [ {"type": "string"}, {"type": "string", "file_uploadable": True}, ] } result = file_helper._find_uploadable_schema_variant(schema) assert result is not None assert result.get("file_uploadable") is True def test_find_uploadable_variant_in_allof(self, file_helper): """Test finding uploadable variant in allOf.""" schema = { "allOf": [ {"type": "string", "file_uploadable": True}, {"minLength": 1}, ] } result = file_helper._find_uploadable_schema_variant(schema) assert result is not None assert result.get("file_uploadable") is True def test_find_uploadable_variant_not_found(self, file_helper): """Test that None is returned when no uploadable variant exists.""" schema = { "anyOf": [ {"type": "string"}, {"type": "null"}, ] } result = file_helper._find_uploadable_schema_variant(schema) assert result is None def test_find_uploadable_variant_prefers_runtime_value_shape(self, file_helper): """Runtime values should select the matching file-bearing schema shape.""" schema = { "anyOf": [ {"type": "string", "file_uploadable": True}, { "type": "array", "items": {"type": "string", "file_uploadable": True}, }, ] } result = file_helper._find_uploadable_schema_variant( schema, value=["/tmp/a.txt", "/tmp/b.txt"], ) assert result is not None assert result["type"] == "array" def test_find_uploadable_variant_falls_back_to_first_file_bearing_variant( self, file_helper ): """No runtime shape match should preserve historical first-match behavior.""" schema = { "anyOf": [ {"type": "string", "file_uploadable": True}, { "type": "array", "items": {"type": "string", "file_uploadable": True}, }, ] } result = file_helper._find_uploadable_schema_variant(schema, value=123) assert result is not None assert result["type"] == "string" def test_find_downloadable_variant_in_anyof(self, file_helper): """Test finding downloadable variant in anyOf.""" schema = { "anyOf": [ {"type": "object", "file_downloadable": True}, {"type": "null"}, ] } result = file_helper._find_downloadable_schema_variant(schema) assert result is not None assert result.get("file_downloadable") is True def test_find_downloadable_variant_in_oneof(self, file_helper): """Test finding downloadable variant in oneOf.""" schema = { "oneOf": [ {"type": "string"}, {"type": "object", "file_downloadable": True}, ] } result = file_helper._find_downloadable_schema_variant(schema) assert result is not None assert result.get("file_downloadable") is True def test_find_downloadable_variant_nested(self, file_helper): """Test finding downloadable variant with nested file_downloadable.""" schema = { "anyOf": [ { "type": "object", "properties": { "file": {"type": "object", "file_downloadable": True} }, }, {"type": "null"}, ] } result = file_helper._find_downloadable_schema_variant(schema) assert result is not None assert result.get("type") == "object" class TestFileUploadWithMixedSchemas: """Test cases for schemas with anyOf/oneOf/allOf alongside properties with file_uploadable.""" def test_upload_from_base_properties_when_anyof_has_no_file_uploadable( self, file_helper, mock_tool ): """Test that file_uploadable in base properties works when sibling has anyOf without file_uploadable.""" mock_tool.input_parameters = { "type": "object", "properties": { "metadata": { "anyOf": [ {"type": "string"}, {"type": "null"}, ] }, "file": { "type": "string", "file_uploadable": True, }, }, } # Should try to upload the file (will fail because file doesn't exist) with pytest.raises(Exception): file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"metadata": "some data", "file": "/path/to/file.txt"}, ) def test_upload_when_root_has_anyof_without_file_uploadable_and_properties_with_file_uploadable( self, file_helper, mock_tool ): """Test upload when root schema has anyOf (no file_uploadable) and properties (with file_uploadable).""" mock_tool.input_parameters = { "type": "object", "anyOf": [ {"required": ["text"]}, {"required": ["file"]}, ], "properties": { "text": {"type": "string"}, "file": {"type": "string", "file_uploadable": True}, }, } # Should try to upload the file with pytest.raises(Exception): file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"file": "/path/to/document.pdf"}, ) def test_metadata_preserved_when_file_upload_with_mixed_schema( self, file_helper, mock_tool ): """Test that non-file properties are preserved when processing mixed schemas.""" mock_tool.input_parameters = { "type": "object", "properties": { "metadata": { "anyOf": [ {"type": "string"}, {"type": "null"}, ] }, "count": {"type": "integer"}, }, } result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"metadata": "test data", "count": 42}, ) # Both values should be preserved unchanged assert result["metadata"] == "test data" assert result["count"] == 42 class TestUrlHelperFunctions: """Test cases for URL-related helper functions.""" def test_is_url_with_http(self): """Test _is_url correctly identifies HTTP URLs.""" assert _is_url("http://example.com/file.jpg") is True assert _is_url("http://localhost:8080/api/file") is True def test_is_url_with_https(self): """Test _is_url correctly identifies HTTPS URLs.""" assert _is_url("https://example.com/file.jpg") is True assert _is_url("https://images.pexels.com/photos/123.jpg") is True def test_is_url_with_long_real_world_urls(self): """Test _is_url handles long real-world URLs with query parameters.""" assert ( _is_url( "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQq0powzLhfi1bakZ0eNSIA_aJ_5UlPsCte1g&s" ) is True ) assert ( _is_url( "https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8bWFjYm9vfGVufDB8fDB8fA%3D%3D&auto=format&fit=crop&w=800&q=60" ) is True ) def test_is_url_with_local_paths(self): """Test _is_url correctly rejects local file paths.""" assert _is_url("/path/to/file.jpg") is False assert _is_url("./relative/path.txt") is False assert _is_url("file.txt") is False assert _is_url("C:\\Windows\\file.txt") is False def test_is_url_with_other_schemes(self): """Test _is_url rejects non-HTTP schemes.""" assert _is_url("ftp://ftp.example.com/file.txt") is False assert _is_url("file:///local/file.txt") is False assert _is_url("mailto:test@example.com") is False def test_is_url_with_invalid_inputs(self): """Test _is_url handles invalid inputs gracefully.""" assert _is_url("") is False assert _is_url("not a url") is False assert _is_url("http://") is False def test_get_extension_from_mimetype_common_types(self): """Test _get_extension_from_mimetype for common types.""" assert _get_extension_from_mimetype("image/jpeg") == ".jpg" assert _get_extension_from_mimetype("image/png") == ".png" assert _get_extension_from_mimetype("application/pdf") == ".pdf" assert _get_extension_from_mimetype("text/plain") == ".txt" assert _get_extension_from_mimetype("application/json") == ".json" def test_get_extension_from_mimetype_unknown_type(self): """Test _get_extension_from_mimetype returns empty for unknown types.""" assert _get_extension_from_mimetype("application/unknown") == "" assert _get_extension_from_mimetype("custom/type") == "" def test_get_extension_from_mimetype_case_insensitive(self): """Test _get_extension_from_mimetype handles case-insensitive mimetypes (RFC 2045).""" # Uppercase variations assert _get_extension_from_mimetype("IMAGE/JPEG") == ".jpg" assert _get_extension_from_mimetype("APPLICATION/PDF") == ".pdf" # Mixed case variations assert _get_extension_from_mimetype("Image/Jpeg") == ".jpg" assert _get_extension_from_mimetype("Application/Pdf") == ".pdf" assert _get_extension_from_mimetype("Text/Plain") == ".txt" # Edge cases assert _get_extension_from_mimetype("IMAGE/png") == ".png" assert _get_extension_from_mimetype("video/MP4") == ".mp4" def test_generate_timestamped_filename(self): """Test _generate_timestamped_filename generates valid filenames.""" filename = _generate_timestamped_filename(".jpg") assert filename.startswith("file_") assert filename.endswith(".jpg") assert len(filename) > 15 # Should have timestamp and unique ID def test_generate_timestamped_filename_no_extension(self): """Test _generate_timestamped_filename works without extension.""" filename = _generate_timestamped_filename("") assert filename.startswith("file_") assert not filename.endswith(".") class TestFetchFileFromUrl: """Test cases for _fetch_file_from_url function.""" @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_success(self, mock_get): """Test successful file fetch from URL.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "image/jpeg"} mock_response.iter_content.return_value = [b"test file content"] mock_response.close = MagicMock() mock_get.return_value = mock_response filename, content, mimetype = _fetch_file_from_url( "https://example.com/image.jpg" ) assert content == b"test file content" assert mimetype == "image/jpeg" assert filename == "image.jpg" mock_get.assert_called_once_with( "https://example.com/image.jpg", stream=True, allow_redirects=False, timeout=(5, 60), ) @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_with_charset_in_content_type(self, mock_get): """Test that charset is stripped from content-type.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "text/html; charset=utf-8"} mock_response.iter_content.return_value = [b""] mock_response.close = MagicMock() mock_get.return_value = mock_response filename, content, mimetype = _fetch_file_from_url( "https://example.com/page.html" ) assert mimetype == "text/html" @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_generates_filename_when_missing(self, mock_get): """Test filename generation when URL has no filename.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "image/png"} mock_response.iter_content.return_value = [b"image data"] mock_response.close = MagicMock() mock_get.return_value = mock_response filename, content, mimetype = _fetch_file_from_url("https://example.com/") assert filename.startswith("file_") assert filename.endswith(".png") @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_generates_filename_when_no_extension(self, mock_get): """Test filename generation when URL filename has no extension.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "application/pdf"} mock_response.iter_content.return_value = [b"pdf data"] mock_response.close = MagicMock() mock_get.return_value = mock_response filename, content, mimetype = _fetch_file_from_url( "https://example.com/document" ) assert filename.startswith("file_") assert filename.endswith(".pdf") @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_failure(self, mock_get): """Test error handling when URL fetch fails.""" mock_response = MagicMock() mock_response.ok = False mock_response.status_code = 404 mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ErrorUploadingFile) as exc_info: _fetch_file_from_url("https://example.com/notfound.jpg") assert "Failed to fetch file from URL" in str(exc_info.value) assert "404" in str(exc_info.value) @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_decodes_percent_encoded_filename(self, mock_get): """Test that percent-encoded characters in URL filenames are decoded.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "application/pdf"} mock_response.iter_content.return_value = [b"document content"] mock_response.close = MagicMock() mock_get.return_value = mock_response # URL with percent-encoded spaces and special characters filename, content, mimetype = _fetch_file_from_url( "https://example.com/My%20Document%20%282024%29.pdf" ) # Filename should be decoded assert filename == "My Document (2024).pdf" assert content == b"document content" assert mimetype == "application/pdf" @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_decodes_unicode_filename(self, mock_get): """Test that percent-encoded unicode characters in URL filenames are decoded.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "image/jpeg"} mock_response.iter_content.return_value = [b"image data"] mock_response.close = MagicMock() mock_get.return_value = mock_response # URL with percent-encoded unicode (e.g., Japanese characters) filename, content, mimetype = _fetch_file_from_url( "https://example.com/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.jpg" ) # Filename should be decoded to unicode assert filename == "ファイル.jpg" @patch("composio.core.models._files.requests.get") def test_fetch_file_from_url_handles_plus_sign_in_filename(self, mock_get): """Test that plus signs in URL paths are preserved (not converted to spaces).""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "text/plain"} mock_response.iter_content.return_value = [b"file content"] mock_response.close = MagicMock() mock_get.return_value = mock_response # Plus signs in path should remain as plus signs (unquote doesn't convert + to space) filename, content, mimetype = _fetch_file_from_url( "https://example.com/file+name.txt" ) assert filename == "file+name.txt" class TestUploadBytesToS3: """Test cases for _upload_bytes_to_s3 function.""" @patch("composio.core.models._files.requests.put") def test_upload_bytes_to_s3_success(self, mock_put): """Test successful upload to S3.""" mock_client = MagicMock() mock_s3_response = MagicMock() mock_s3_response.key = "s3-key-123" mock_s3_response.new_presigned_url = "https://s3.example.com/upload" mock_client.post.return_value = mock_s3_response mock_put_response = MagicMock() mock_put_response.status_code = 200 mock_put.return_value = mock_put_response result = _upload_bytes_to_s3( client=mock_client, filename="test.jpg", content=b"file content", mimetype="image/jpeg", tool="TEST_TOOL", toolkit="test_toolkit", ) assert result == "s3-key-123" mock_client.post.assert_called_once() mock_put.assert_called_once_with( url="https://s3.example.com/upload", data=b"file content", headers={"Content-Type": "image/jpeg"}, timeout=(5, 60), ) @patch("composio.core.models._files.requests.put") def test_upload_bytes_to_s3_failure(self, mock_put): """Test error handling when S3 upload fails.""" mock_client = MagicMock() mock_s3_response = MagicMock() mock_s3_response.key = "s3-key-123" mock_s3_response.new_presigned_url = "https://s3.example.com/upload" mock_client.post.return_value = mock_s3_response mock_put_response = MagicMock() mock_put_response.status_code = 500 mock_put.return_value = mock_put_response with pytest.raises(ErrorUploadingFile) as exc_info: _upload_bytes_to_s3( client=mock_client, filename="test.jpg", content=b"file content", mimetype="image/jpeg", tool="TEST_TOOL", toolkit="test_toolkit", ) assert "Failed to upload to S3" in str(exc_info.value) @patch("composio.core.models._files.requests.put") def test_upload_bytes_to_s3_timeout(self, mock_put): """Test request timeouts are reported as upload errors.""" mock_client = MagicMock() mock_s3_response = MagicMock() mock_s3_response.key = "s3-key-123" mock_s3_response.new_presigned_url = "https://s3.example.com/upload?token=abc" mock_client.post.return_value = mock_s3_response # The exception text itself carries the presigned URL (incl. token), as # real urllib3 errors do — the SDK must not surface it in the message. mock_put.side_effect = requests.exceptions.Timeout( "HTTPSConnectionPool(host='s3.example.com', port=443): " "Max retries exceeded with url: /upload?token=abc" ) with pytest.raises(ErrorUploadingFile) as exc_info: _upload_bytes_to_s3( client=mock_client, filename="test.jpg", content=b"file content", mimetype="image/jpeg", tool="TEST_TOOL", toolkit="test_toolkit", ) assert "Failed to upload to S3" in str(exc_info.value) assert "token=abc" not in str(exc_info.value) class TestFileUploadableFromUrl: """Test cases for FileUploadable.from_url and from_path with URLs.""" @patch("composio.core.models._files._upload_bytes_to_s3") @patch("composio.core.models._files._fetch_file_from_url") def test_from_url_success(self, mock_fetch, mock_upload): """Test successful FileUploadable creation from URL.""" mock_fetch.return_value = ("image.jpg", b"image data", "image/jpeg") mock_upload.return_value = "s3-key-abc" mock_client = MagicMock() result = FileUploadable.from_url( client=mock_client, url="https://example.com/image.jpg", tool="TEST_TOOL", toolkit="test_toolkit", ) assert result.name == "image.jpg" assert result.mimetype == "image/jpeg" assert result.s3key == "s3-key-abc" mock_fetch.assert_called_once_with("https://example.com/image.jpg") mock_upload.assert_called_once() @patch("composio.core.models._files._upload_bytes_to_s3") @patch("composio.core.models._files._fetch_file_from_url") def test_from_path_detects_url(self, mock_fetch, mock_upload): """Test that from_path correctly detects and handles URLs.""" mock_fetch.return_value = ("photo.png", b"photo data", "image/png") mock_upload.return_value = "s3-key-xyz" mock_client = MagicMock() result = FileUploadable.from_path( client=mock_client, file="https://images.example.com/photo.png", tool="SEND_EMAIL", toolkit="gmail", ) assert result.name == "photo.png" assert result.mimetype == "image/png" assert result.s3key == "s3-key-xyz" mock_fetch.assert_called_once_with("https://images.example.com/photo.png") @patch("composio.core.models._files._fetch_file_from_url") def test_from_url_propagates_fetch_error(self, mock_fetch): """Test that fetch errors are propagated correctly.""" mock_fetch.side_effect = ErrorUploadingFile("Fetch failed") mock_client = MagicMock() with pytest.raises(ErrorUploadingFile) as exc_info: FileUploadable.from_url( client=mock_client, url="https://example.com/missing.jpg", tool="TEST_TOOL", toolkit="test_toolkit", ) assert "Fetch failed" in str(exc_info.value) def test_before_file_upload_hook_receives_source_url(self): """from_path emits ``source="url"`` to the hook for http(s) inputs. We abort from the hook to avoid the downstream network path; the pre-abort capture is what we're asserting on. """ from composio.exceptions import FileUploadAbortedError mock_client = MagicMock() seen = {} def hook(ctx): seen.update(ctx) return False with pytest.raises(FileUploadAbortedError): FileUploadable.from_path( client=mock_client, file="https://example.com/photo.png", tool="SEND_EMAIL", toolkit="gmail", before_file_upload=hook, ) assert seen == { "path": "https://example.com/photo.png", "source": "url", "tool": "SEND_EMAIL", "toolkit": "gmail", } def test_before_file_upload_hook_receives_source_path(self, tmp_path): """from_path emits ``source="path"`` to the hook for local inputs.""" from composio.exceptions import FileUploadAbortedError f = tmp_path / "doc.txt" f.write_text("hello") mock_client = MagicMock() seen = {} def hook(ctx): seen.update(ctx) return False with pytest.raises(FileUploadAbortedError): FileUploadable.from_path( client=mock_client, file=str(f), tool="MY_TOOL", toolkit="my_toolkit", before_file_upload=hook, ) assert seen == { "path": str(f), "source": "path", "tool": "MY_TOOL", "toolkit": "my_toolkit", } def test_url_hook_returning_local_path_routes_through_path_branch(self, tmp_path): """A hook that rewrites a URL into a local path must NOT be fed to ``from_url``. It has to route back into the local-file branch so the allowlist / denylist / existence checks all run.""" from composio.exceptions import SDKFileNotFoundError # A path that's syntactically a path but does not exist — if routing # is correct, we'll get SDKFileNotFoundError from the local branch. # If the bug is still there, we'd hit `from_url` and the URL fetch # would explode (or worse, succeed) instead. rewritten = str(tmp_path / "does-not-exist.txt") def hook(ctx): assert ctx["source"] == "url" return rewritten mock_client = MagicMock() with pytest.raises(SDKFileNotFoundError): FileUploadable.from_path( client=mock_client, file="https://example.com/photo.png", tool="T", toolkit="tk", before_file_upload=hook, ) @patch("composio.core.models._files._fetch_file_from_url") @patch("composio.core.models._files._upload_bytes_to_s3") def test_path_hook_returning_url_routes_through_url_branch( self, mock_upload, mock_fetch, tmp_path ): """Inverse of the above: a hook on a local path that returns a URL must route through ``from_url``, not stat the URL string as a file.""" f = tmp_path / "local.txt" f.write_text("hi") mock_fetch.return_value = ("photo.png", b"x", "image/png") mock_upload.return_value = "s3-key" def hook(ctx): assert ctx["source"] == "path" return "https://example.com/photo.png" mock_client = MagicMock() result = FileUploadable.from_path( client=mock_client, file=str(f), tool="T", toolkit="tk", before_file_upload=hook, ) # If routing worked, the URL fetch path was taken. mock_fetch.assert_called_once_with("https://example.com/photo.png") assert result.s3key == "s3-key" class TestFileHelperWithUrls: """Test cases for FileHelper handling URLs in file uploads.""" @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_file_uploads_with_url( self, mock_from_path, file_helper, mock_tool ): """Test that URLs are correctly processed in substitute_file_uploads.""" mock_tool.input_parameters = { "type": "object", "properties": { "attachment": {"type": "string", "file_uploadable": True}, }, } mock_uploadable = MagicMock() mock_uploadable.model_dump.return_value = { "name": "image.jpg", "mimetype": "image/jpeg", "s3key": "s3-key-123", } mock_from_path.return_value = mock_uploadable result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"attachment": "https://example.com/image.jpg"}, ) assert result["attachment"] == { "name": "image.jpg", "mimetype": "image/jpeg", "s3key": "s3-key-123", } mock_from_path.assert_called_once() @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_file_uploads_with_url_in_anyof( self, mock_from_path, file_helper, mock_tool ): """Test URL handling in anyOf schema variants.""" mock_tool.input_parameters = { "type": "object", "properties": { "file": { "anyOf": [ {"type": "string", "file_uploadable": True}, {"type": "null"}, ] }, }, } mock_uploadable = MagicMock() mock_uploadable.model_dump.return_value = { "name": "doc.pdf", "mimetype": "application/pdf", "s3key": "s3-key-456", } mock_from_path.return_value = mock_uploadable result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={"file": "https://docs.example.com/doc.pdf"}, ) assert result["file"] == { "name": "doc.pdf", "mimetype": "application/pdf", "s3key": "s3-key-456", } @patch("composio.core.models._files.FileUploadable.from_path") def test_substitute_file_uploads_array_with_urls( self, mock_from_path, file_helper, mock_tool ): """Test URL handling in arrays with file_uploadable items.""" mock_tool.input_parameters = { "type": "object", "properties": { "attachments": { "type": "array", "items": {"type": "string", "file_uploadable": True}, }, }, } mock_uploadable1 = MagicMock() mock_uploadable1.model_dump.return_value = { "name": "file1.jpg", "mimetype": "image/jpeg", "s3key": "key1", } mock_uploadable2 = MagicMock() mock_uploadable2.model_dump.return_value = { "name": "file2.png", "mimetype": "image/png", "s3key": "key2", } mock_from_path.side_effect = [mock_uploadable1, mock_uploadable2] result = file_helper._substitute_file_uploads_recursively( tool=mock_tool, schema=mock_tool.input_parameters, request={ "attachments": [ "https://example.com/file1.jpg", "https://example.com/file2.png", ] }, ) assert len(result["attachments"]) == 2 assert result["attachments"][0]["s3key"] == "key1" assert result["attachments"][1]["s3key"] == "key2" class TestTruncateFilename: """Test cases for _truncate_filename function. Long filenames are common with public bucket URLs containing hashes or UUIDs. These can cause issues, so they are replaced with timestamped filenames. """ def test_truncate_filename_short_unchanged(self): """Short filenames should not be modified.""" assert _truncate_filename("document.pdf") == "document.pdf" assert _truncate_filename("image.jpg") == "image.jpg" assert _truncate_filename("file.txt") == "file.txt" def test_truncate_filename_at_limit_unchanged(self): """Filenames exactly at the limit should not be modified.""" # Create a filename exactly at the limit (100 chars by default) name = "a" * 95 + ".pdf" # 95 + 4 = 99 chars assert _truncate_filename(name) == name name_at_limit = "a" * 96 + ".pdf" # 96 + 4 = 100 chars assert _truncate_filename(name_at_limit) == name_at_limit def test_truncate_filename_long_generates_timestamped(self): """Long filenames should be replaced with timestamped filename.""" long_name = "a" * 150 + ".pdf" result = _truncate_filename(long_name) assert len(result) < _MAX_FILENAME_LENGTH assert result.startswith("file_") assert result.endswith(".pdf") def test_truncate_filename_preserves_extension(self): """Extension should be preserved when generating timestamped filename.""" test_cases = [ ("very_long_" * 20 + ".docx", ".docx"), ("hash_" * 30 + ".jpg", ".jpg"), ("uuid_" * 25 + ".png", ".png"), ("data_" * 40 + ".json", ".json"), ] for long_name, expected_ext in test_cases: result = _truncate_filename(long_name) assert result.endswith(expected_ext), ( f"Expected {result} to end with {expected_ext}" ) def test_truncate_filename_long_hash_url(self): """Hash-based filenames from URLs should be truncated.""" # Typical long hash filename from public bucket URLs (needs to be >100 chars) hash_filename = "8f14e45fceea167a5a36dedd4bea2543_5d41402abc4b2a76b9719d911017c592_extra_hash_data_to_exceed_limit_download.jpg" assert len(hash_filename) > _MAX_FILENAME_LENGTH # Verify test setup result = _truncate_filename(hash_filename) assert len(result) <= _MAX_FILENAME_LENGTH assert result.startswith("file_") assert result.endswith(".jpg") def test_truncate_filename_multiple_hashes(self): """Multiple concatenated hashes should be truncated.""" # 32 char hash * 5 = 160 chars + extension multi_hash = "8f14e45fceea167a5a36dedd4bea2543" * 5 + ".pdf" result = _truncate_filename(multi_hash) assert len(result) <= _MAX_FILENAME_LENGTH assert result.endswith(".pdf") def test_truncate_filename_no_extension(self): """Long filenames without extension should still be truncated.""" long_name = "a" * 150 result = _truncate_filename(long_name) assert len(result) <= _MAX_FILENAME_LENGTH assert result.startswith("file_") # Should not end with a dot assert not result.endswith(".") def test_truncate_filename_custom_max_length(self): """Custom max_length parameter should be respected.""" name = "a" * 60 + ".txt" # 64 chars total # With default limit (100), should be unchanged assert _truncate_filename(name) == name # With custom limit (50), should be truncated result = _truncate_filename(name, max_length=50) assert len(result) <= 50 assert result.startswith("file_") assert result.endswith(".txt") def test_truncate_filename_edge_case_one_over_limit(self): """Filename one character over limit should be truncated.""" # Create filename exactly one char over the limit name = "a" * 97 + ".pdf" # 97 + 4 = 101 chars (one over default 100) result = _truncate_filename(name) assert len(result) < _MAX_FILENAME_LENGTH assert result.startswith("file_") assert result.endswith(".pdf") def test_truncate_filename_long_extension(self): """Long extensions should be preserved when truncating.""" long_name = "file_" * 30 + ".dockerfile" result = _truncate_filename(long_name) assert result.endswith(".dockerfile") assert result.startswith("file_") def test_truncate_filename_multiple_dots_preserves_last_extension(self): """Filename with multiple dots should preserve only the last extension.""" long_name = "archive" * 20 + ".backup.tar.gz" result = _truncate_filename(long_name) # rsplit(".", 1) takes the part after the last dot assert result.endswith(".gz") class TestFetchFileFromUrlWithTruncation: """Test cases for _fetch_file_from_url with filename truncation.""" @patch("composio.core.models._files.requests.get") def test_fetch_truncates_long_filename(self, mock_get): """Long filenames from URLs should be truncated.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "application/pdf"} mock_response.iter_content.return_value = [b"test content"] mock_response.close = MagicMock() mock_get.return_value = mock_response # Create a very long filename (hash-based, common in public buckets) long_filename = "8f14e45fceea167a5a36dedd4bea2543" * 5 + ".pdf" filename, content, mimetype = _fetch_file_from_url( f"https://bucket.example.com/files/{long_filename}" ) assert len(filename) <= _MAX_FILENAME_LENGTH assert filename.startswith("file_") assert filename.endswith(".pdf") assert content == b"test content" @patch("composio.core.models._files.requests.get") def test_fetch_preserves_short_filename(self, mock_get): """Short filenames should be preserved unchanged.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "image/jpeg"} mock_response.iter_content.return_value = [b"image data"] mock_response.close = MagicMock() mock_get.return_value = mock_response filename, content, mimetype = _fetch_file_from_url( "https://example.com/photo.jpg" ) assert filename == "photo.jpg" @patch("composio.core.models._files.requests.get") def test_fetch_truncates_after_adding_extension(self, mock_get): """Truncation should happen after extension is appended.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "application/pdf"} mock_response.iter_content.return_value = [b"pdf content"] mock_response.close = MagicMock() mock_get.return_value = mock_response # URL without extension - extension will be added from mimetype long_name_no_ext = "8f14e45fceea167a5a36dedd4bea2543" * 4 filename, content, mimetype = _fetch_file_from_url( f"https://bucket.example.com/{long_name_no_ext}" ) # Should be truncated and have .pdf extension assert len(filename) <= _MAX_FILENAME_LENGTH assert filename.endswith(".pdf") @patch("composio.core.models._files.requests.get") def test_fetch_generated_filename_not_truncated(self, mock_get): """Generated timestamped filenames (when URL has no filename) should be short enough.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "image/png"} mock_response.iter_content.return_value = [b"data"] mock_response.close = MagicMock() mock_get.return_value = mock_response # URL with no filename - will generate a timestamped one filename, content, mimetype = _fetch_file_from_url("https://example.com/") # Generated filename should be naturally short assert filename.startswith("file_") assert filename.endswith(".png") assert len(filename) < 50 # Timestamped names are short @patch("composio.core.models._files.requests.get") def test_fetch_long_real_world_url(self, mock_get): """Long real-world URLs should be handled correctly.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "application/pdf"} mock_response.iter_content.return_value = [b"test content"] mock_response.close = MagicMock() mock_get.return_value = mock_response # Real-world long URL example (no extension, relies on mimetype) long_url = ( "https://encrypted-tbn0.gstatic.com/images?q=" "tbn:ANd9GcQq0powzLhfi1bakZ0eNSIA_aJ_5UlPsCte1g&s" ) filename, content, mimetype = _fetch_file_from_url(long_url) assert len(filename) <= _MAX_FILENAME_LENGTH assert filename.startswith("file_") assert filename.endswith(".pdf") assert content == b"test content" class TestResponseSizeLimit: """Test response size limiting.""" @patch("composio.core.models._files.requests.get") def test_rejects_oversized_content_length(self, mock_get): """Files with Content-Length > max_size should be rejected early.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"Content-Length": "200000000"} # 200MB mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ResponseTooLargeError): _fetch_file_from_url( "https://example.com/large.zip", max_size=100 * 1024 * 1024 ) @patch("composio.core.models._files.requests.get") def test_rejects_oversized_during_streaming(self, mock_get): """Files that exceed max_size during download should be rejected.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {} # No Content-Length # Return 20MB of data in chunks mock_response.iter_content.return_value = [ b"x" * 1024 * 1024 for _ in range(20) ] mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ResponseTooLargeError): _fetch_file_from_url( "https://example.com/large.zip", max_size=10 * 1024 * 1024 ) @patch("composio.core.models._files.requests.get") def test_accepts_file_within_limit(self, mock_get): """Files within size limit should be accepted.""" mock_response = MagicMock() mock_response.ok = True mock_response.status_code = 200 mock_response.headers = {"content-type": "image/jpeg", "Content-Length": "1000"} mock_response.iter_content.return_value = [b"x" * 1000] mock_response.close = MagicMock() mock_get.return_value = mock_response filename, content, mimetype = _fetch_file_from_url( "https://example.com/image.jpg", max_size=10 * 1024 * 1024 ) assert len(content) == 1000 assert mimetype == "image/jpeg" class TestRedirectHandling: """Test redirect handling (redirects should be rejected).""" @patch("composio.core.models._files.requests.get") def test_rejects_redirect_302(self, mock_get): """302 redirects should be rejected with clear error message.""" mock_response = MagicMock() mock_response.status_code = 302 mock_response.headers = {"Location": "https://example.com/final.jpg"} mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ErrorUploadingFile, match="redirect"): _fetch_file_from_url("https://example.com/redirect") @patch("composio.core.models._files.requests.get") def test_rejects_redirect_301(self, mock_get): """301 redirects should be rejected.""" mock_response = MagicMock() mock_response.status_code = 301 mock_response.headers = {"Location": "https://example.com/"} mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ErrorUploadingFile, match="redirect"): _fetch_file_from_url("https://example.com/test") @patch("composio.core.models._files.requests.get") def test_rejects_redirect_307(self, mock_get): """307 redirects should be rejected.""" mock_response = MagicMock() mock_response.status_code = 307 mock_response.headers = {"Location": "https://example.com/"} mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ErrorUploadingFile, match="redirect"): _fetch_file_from_url("https://example.com/test") @patch("composio.core.models._files.requests.get") def test_rejects_redirect_308(self, mock_get): """308 redirects should be rejected.""" mock_response = MagicMock() mock_response.status_code = 308 mock_response.headers = {"Location": "https://example.com/"} mock_response.close = MagicMock() mock_get.return_value = mock_response with pytest.raises(ErrorUploadingFile, match="redirect"): _fetch_file_from_url("https://example.com/test") class TestS3UploadErrorHandling: """Test S3 upload error handling.""" @patch("composio.core.models._files.requests.put") def test_403_is_treated_as_error(self, mock_put): """HTTP 403 should be treated as upload failure.""" mock_client = MagicMock() mock_s3_response = MagicMock() mock_s3_response.key = "s3-key" mock_s3_response.new_presigned_url = "https://s3.example.com/upload" mock_client.post.return_value = mock_s3_response mock_put_response = MagicMock() mock_put_response.status_code = 403 mock_put.return_value = mock_put_response with pytest.raises(ErrorUploadingFile, match="403"): _upload_bytes_to_s3( client=mock_client, filename="test.jpg", content=b"data", mimetype="image/jpeg", tool="TEST", toolkit="test", ) @patch("composio.core.models._files.requests.put") def test_200_is_success(self, mock_put): """HTTP 200 should be treated as success.""" mock_client = MagicMock() mock_s3_response = MagicMock() mock_s3_response.key = "s3-key" mock_s3_response.new_presigned_url = "https://s3.example.com/upload" mock_client.post.return_value = mock_s3_response mock_put_response = MagicMock() mock_put_response.status_code = 200 mock_put.return_value = mock_put_response result = _upload_bytes_to_s3( client=mock_client, filename="test.jpg", content=b"data", mimetype="image/jpeg", tool="TEST", toolkit="test", ) assert result == "s3-key" @patch("composio.core.models._files.requests.put") def test_500_is_treated_as_error(self, mock_put): """HTTP 500 should be treated as upload failure.""" mock_client = MagicMock() mock_s3_response = MagicMock() mock_s3_response.key = "s3-key" mock_s3_response.new_presigned_url = "https://s3.example.com/upload" mock_client.post.return_value = mock_s3_response mock_put_response = MagicMock() mock_put_response.status_code = 500 mock_put.return_value = mock_put_response with pytest.raises(ErrorUploadingFile, match="500"): _upload_bytes_to_s3( client=mock_client, filename="test.jpg", content=b"data", mimetype="image/jpeg", tool="TEST", toolkit="test", ) class TestUrlSanitization: """Test URL sanitization for logging.""" def test_sanitizes_query_params(self): """Query parameters should be redacted in logs.""" url = "https://example.com/file?token=secret123&key=abc" sanitized = _sanitize_url_for_logging(url) assert "secret123" not in sanitized assert "abc" not in sanitized assert "[REDACTED]" in sanitized def test_preserves_url_without_query(self): """URLs without query params should be unchanged.""" url = "https://example.com/path/to/file.jpg" sanitized = _sanitize_url_for_logging(url) assert sanitized == url def test_preserves_path(self): """Path should be preserved when redacting query params.""" url = "https://example.com/path/to/file.jpg?token=secret" sanitized = _sanitize_url_for_logging(url) assert "/path/to/file.jpg" in sanitized assert "example.com" in sanitized def test_handles_empty_query(self): """URLs with empty query string should not have [REDACTED].""" url = "https://example.com/file.jpg" sanitized = _sanitize_url_for_logging(url) assert "[REDACTED]" not in sanitized class TestFileDownloadablePathTraversal: """SEC-316: server-controlled `name` must not escape the output dir.""" def _mock_response(self, content: bytes = b"data") -> MagicMock: response = MagicMock() response.status_code = 200 response.iter_content = lambda chunk_size: [content] response.close = MagicMock() return response def test_relative_traversal_is_neutralized(self, tmp_path): outdir = tmp_path / "safe" # outdir does not need to exist yet — download() creates it. f = FileDownloadable( name="../../../PWNED.sh", mimetype="application/octet-stream", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(b"#!/bin/sh\n"), ): written = f.download(outdir) # Traversal sequence collapsed to basename and stayed inside outdir. assert written == outdir / "PWNED.sh" assert written.resolve().is_relative_to(outdir.resolve()) assert not (tmp_path / "PWNED.sh").exists() assert written.read_bytes() == b"#!/bin/sh\n" def test_absolute_path_is_neutralized(self, tmp_path): outdir = tmp_path / "safe" f = FileDownloadable( name="/etc/passwd", mimetype="application/octet-stream", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(b"x"), ): written = f.download(outdir) # `Path('/etc/passwd').name == 'passwd'` — absolute path is stripped. assert written == outdir / "passwd" assert written.resolve().is_relative_to(outdir.resolve()) def test_dotdot_only_name_is_rejected(self, tmp_path): """`Path('..').name == '..'`. Basename strip alone wouldn't catch it, but the second-line `is_relative_to()` check does.""" from composio.exceptions import ErrorDownloadingFile outdir = tmp_path / "safe" f = FileDownloadable( name="..", mimetype="application/octet-stream", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(), ): with pytest.raises(ErrorDownloadingFile, match="Path traversal detected"): f.download(outdir) # No file was written under the parent. assert not (tmp_path / "x").exists() def test_safe_filename_passes_through(self, tmp_path): outdir = tmp_path / "safe" f = FileDownloadable( name="report.pdf", mimetype="application/pdf", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(b"%PDF-1.4"), ): written = f.download(outdir) assert written == outdir / "report.pdf" assert written.read_bytes() == b"%PDF-1.4" def test_download_uses_timeout(self, tmp_path): outdir = tmp_path / "safe" f = FileDownloadable( name="report.pdf", mimetype="application/pdf", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(b"%PDF-1.4"), ) as mock_get: f.download(outdir) mock_get.assert_called_once_with( url="https://example.com/file", stream=True, timeout=(5, 60), ) def test_download_timeout_raises_error(self, tmp_path): outdir = tmp_path / "safe" f = FileDownloadable( name="report.pdf", mimetype="application/pdf", s3url="https://example.com/file?token=abc", ) with patch( "composio.core.models._files.requests.get", side_effect=requests.exceptions.Timeout( "Max retries exceeded with url: /file?token=abc" ), ): with pytest.raises(ErrorDownloadingFile) as exc_info: f.download(outdir) assert "Error downloading file" in str(exc_info.value) assert "token=abc" not in str(exc_info.value) def test_download_stream_timeout_raises_error(self, tmp_path): outdir = tmp_path / "safe" response = self._mock_response() response.iter_content = MagicMock( side_effect=requests.exceptions.ReadTimeout( "Max retries exceeded with url: /file?token=abc" ) ) f = FileDownloadable( name="report.pdf", mimetype="application/pdf", s3url="https://example.com/file?token=abc", ) with patch( "composio.core.models._files.requests.get", return_value=response, ): with pytest.raises(ErrorDownloadingFile) as exc_info: f.download(outdir) assert "Error downloading file" in str(exc_info.value) assert "token=abc" not in str(exc_info.value) response.close.assert_called_once() def test_download_non_200_redacts_url_and_closes(self, tmp_path): outdir = tmp_path / "safe" response = self._mock_response() response.status_code = 403 f = FileDownloadable( name="report.pdf", mimetype="application/pdf", s3url="https://example.com/file?token=abc", ) with patch( "composio.core.models._files.requests.get", return_value=response, ): with pytest.raises(ErrorDownloadingFile) as exc_info: f.download(outdir) assert "token=abc" not in str(exc_info.value) response.close.assert_called_once() def test_basename_collapse_through_subdir_is_rejected(self, tmp_path): """`Path('foo/..').name == '..'` — the basename strip of a name that traverses *through* a subdir collapses to `..`, then the `is_relative_to()` check rejects it. Explicit coverage of the residual-`..` path through basename normalization (the plain `..` test covers the no-subdir case).""" from composio.exceptions import ErrorDownloadingFile outdir = tmp_path / "safe" f = FileDownloadable( name="foo/..", mimetype="application/octet-stream", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(), ): with pytest.raises(ErrorDownloadingFile, match="Path traversal detected"): f.download(outdir) # SEC-316 P3.1: check runs before mkdir, so outdir is not created # as a side effect of a rejected payload. assert not outdir.exists() def test_empty_name_safe_fails_at_write_time(self, tmp_path): """`Path('').name == ''` — `outdir / ''` resolves to `outdir` itself, which passes the containment check (a path is relative to itself). The write then fails with `IsADirectoryError` because the target is the directory. Documents the safe-fail behavior so a future change to the check cannot silently weaken it without breaking this test.""" outdir = tmp_path / "safe" f = FileDownloadable( name="", mimetype="application/octet-stream", s3url="https://example.com/file", ) with patch( "composio.core.models._files.requests.get", return_value=self._mock_response(b"x"), ): with pytest.raises(IsADirectoryError): f.download(outdir) # outdir got created (mkdir runs after the check, which passed), # but no file was written inside it. assert outdir.is_dir() assert list(outdir.iterdir()) == [] class TestEnhanceSchemaDescriptionsEmptySchema: """Regression tests for the empty-input-parameters crash. MCP-backed toolkits can return `input_parameters: {}` for tools with no required arguments (the same convention the API uses for missing output schemas). Before the fix, `FileHelper.enhance_schema_descriptions` — called unconditionally on every fetched tool's input_parameters from `Tools._get()` — raised `KeyError: 'properties'` on that shape, crashing the public `Composio.tools.get(...)` call. The sibling `FileHelper.process_file_uploadable_schema` already guarded this with `if "properties" not in schema: return schema`. This adds the same guard to `enhance_schema_descriptions`. See https://github.com/ComposioHQ/composio/issues/3354 for the related TS-side issue. """ def test_empty_schema_does_not_raise(self, file_helper): """schema={} must not raise — it means 'no declared schema'.""" # Pre-fix: KeyError: 'properties' result = file_helper.enhance_schema_descriptions(schema={}) assert result == {} def test_schema_without_properties_returns_unchanged(self, file_helper): """A schema with metadata but no `properties` key is returned as-is. Mirrors how `process_file_uploadable_schema` treats the same input. """ schema = {"type": "object", "additionalProperties": False} result = file_helper.enhance_schema_descriptions(schema=schema) assert result == {"type": "object", "additionalProperties": False} def test_schema_with_empty_properties_dict_is_handled(self, file_helper): """`properties: {}` is valid JSON Schema (declares no fields). The loop iterates zero times and the schema is returned unchanged. """ schema = {"type": "object", "properties": {}} result = file_helper.enhance_schema_descriptions(schema=schema) assert result == {"type": "object", "properties": {}} def test_populated_schema_still_enhanced(self, file_helper): """Non-empty schemas continue to receive type-hint enhancements. Guard against regressing the enhancement behavior while fixing the empty-schema crash. """ schema = { "type": "object", "properties": { "query": {"type": "string", "description": "Search term"}, }, "required": ["query"], } result = file_helper.enhance_schema_descriptions(schema=schema) description = result["properties"]["query"]["description"] # The description must have been mutated from the input and must # mention the type and the required-marker. Assertions are token- # based to avoid coupling to the exact phrasing in # enhance_schema_descriptions. assert description != "Search term" assert "string" in description assert "required" in description class TestFromPathSensitiveGuard: """`FileUploadable.from_path` is the single upload primitive in the Python SDK; the sensitive-path denylist must fire there before any file read or network round-trip (parity with the TS core SDK and the CLI fix for issue #3746 / GHSA-hp3h-89pf-5q58).""" def test_from_path_blocks_ssh_private_key(self, mock_client): p = Path.home() / ".ssh" / "id_rsa" with pytest.raises(SensitiveFilePathBlockedError): FileUploadable.from_path( client=mock_client, file=str(p), tool="GMAIL_SEND_EMAIL", toolkit="gmail", ) # The guard runs before the SDK contacts the API for a presigned URL. mock_client.post.assert_not_called() def test_from_path_blocks_dotenv_basename(self, mock_client): p = Path(tempfile.gettempdir()) / ".env" with pytest.raises(SensitiveFilePathBlockedError): FileUploadable.from_path( client=mock_client, file=str(p), tool="GMAIL_SEND_EMAIL", toolkit="gmail", ) mock_client.post.assert_not_called() def test_from_path_opt_out_disables_guard(self, mock_client): """`sensitive_file_upload_protection=False` restores the legacy (unguarded) behavior; the denylist no longer raises for that path.""" # A non-existent basename under `.ssh`: the `.ssh` segment still trips the # denylist when protection is on, but the file never exists, so with # protection off we deterministically hit the missing-file path (no real # key read, no network) rather than the block error. p = Path.home() / ".ssh" / "composio-does-not-exist-guard-test" with pytest.raises(Exception) as exc_info: FileUploadable.from_path( client=mock_client, file=str(p), tool="GMAIL_SEND_EMAIL", toolkit="gmail", sensitive_file_upload_protection=False, ) assert not isinstance(exc_info.value, SensitiveFilePathBlockedError) # Guard skipped, so the failure is downstream (missing file), before any # presigned-URL request. mock_client.post.assert_not_called()