Files
2026-07-13 12:38:34 +08:00

2850 lines
106 KiB
Python

"""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"<html></html>"]
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()