Files
composiohq__composio/python/tests/test_upload_dir_allowlist.py
Alberto Schiabel b1c6671b20 fix(py): send Content-Type on S3 PUTs and parse Content-Length (#4168)
This PR:
- fixes #4153
- supersedes https://github.com/ComposioHQ/composio/pull/4154 (fork PR
authored against a pre-#4146 tree; its tests patch `requests.put`, which
`upload()` no longer calls after #4146, so they fail on merge)
- adds `parse_content_length()` in `composio.utils.url_safety`, shared
by both URL fetch helpers (`_files.py::_fetch_file_from_url` and
`tool_router_session_files.py::_fetch_url_bytes`), so a
malformed/negative remote `Content-Length` degrades to unknown size
under the streamed byte count instead of raising a raw `ValueError`
- converges the file and bytes upload paths onto
`_upload_to_presigned_url()`: one PUT that sends the `Content-Type` the
presign request was signed with and raises `ErrorUploadingFile` carrying
the HTTP status (a 403 no longer collapses into a path-only error)
- single-sources the presign wire shape via
`_request_presigned_upload()`; `from_path()` forwards the exact mimetype
it minted, so signed and sent content types cannot drift
- mirrors the TypeScript SDK: `uploadFileToS3` funnels path/URL/File
inputs through one uploader that always sends `Content-Type` and throws
on non-2xx, and `readResponseBodyWithLimit` trusts `Content-Length` only
as a hint
- adds tests against the `safe_request` seam
(`test_file_upload_robustness.py`, plus one malformed-header case for
`RemoteFile.buffer()`)

## Context

Both defects in #4153 were symptoms of two parallel upload paths
drifting: the bytes path sent `Content-Type` and raised with the status,
the file path did neither. Patching the symptom in place (as #4154
proposed) would have left three presigned PUT sites and two error
contracts in the tree; this PR deletes the drift vector instead. Full
suite green (1320 passed), `make chk` and `make snt` clean; Python-only
change, so no changeset per `AGENTS.md`.

---------

Co-authored-by: Mustaqeem66 <265153888+Mustaqeem66@users.noreply.github.com>
2026-08-18 19:09:02 +02:00

252 lines
9.0 KiB
Python

"""Tests for the auto-upload directory allowlist (parity with TS sdk)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
from composio.core.models.base import allow_tracking
from composio.exceptions import (
FileUploadPathNotAllowedError,
SDKFileNotFoundError,
)
from composio.utils.upload_dir_allowlist import (
assert_path_inside_upload_dirs,
get_default_upload_dir,
resolve_effective_upload_allowlist,
)
@pytest.fixture(autouse=True)
def disable_telemetry():
token = allow_tracking.set(False)
yield
allow_tracking.reset(token)
class TestResolveEffectiveUploadAllowlist:
def test_none_returns_default(self):
result = resolve_effective_upload_allowlist(None)
default = get_default_upload_dir()
assert default is not None, "Expected a default upload dir in this env."
assert result == [default]
def test_empty_list_replaces_default(self):
assert resolve_effective_upload_allowlist([]) == []
def test_false_means_no_local_paths(self):
# Explicit "reject all local paths during auto-upload". Equivalent to []
# but reads better at call sites.
assert resolve_effective_upload_allowlist(False) == []
def test_explicit_list_replaces_default(self, tmp_path: Path):
result = resolve_effective_upload_allowlist([str(tmp_path)])
assert result == [tmp_path.resolve()]
def test_tilde_is_expanded(self):
result = resolve_effective_upload_allowlist(["~"])
assert result == [Path.home().resolve()]
def test_skips_blank_and_non_string_entries(self, tmp_path: Path):
result = resolve_effective_upload_allowlist([str(tmp_path), "", " "])
assert result == [tmp_path.resolve()]
def test_deduplicates_entries(self, tmp_path: Path):
result = resolve_effective_upload_allowlist([str(tmp_path), str(tmp_path)])
assert result == [tmp_path.resolve()]
class TestAssertPathInsideUploadDirs:
def test_accepts_direct_child(self, tmp_path: Path):
allowed = tmp_path
f = allowed / "a.txt"
f.write_text("hi")
assert_path_inside_upload_dirs(str(f), [allowed])
def test_accepts_nested_child(self, tmp_path: Path):
allowed = tmp_path
nested = allowed / "sub" / "deeper"
nested.mkdir(parents=True)
f = nested / "a.txt"
f.write_text("hi")
assert_path_inside_upload_dirs(str(f), [allowed])
def test_rejects_path_outside(self, tmp_path: Path):
allowed = tmp_path / "inside"
outside = tmp_path / "outside"
allowed.mkdir()
outside.mkdir()
f = outside / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError):
assert_path_inside_upload_dirs(str(f), [allowed])
def test_enforces_component_boundary(self, tmp_path: Path):
# /tmp/foo must NOT accept /tmp/foo-bar/x.
allowed = tmp_path / "foo"
sibling = tmp_path / "foo-bar"
allowed.mkdir()
sibling.mkdir()
f = sibling / "x.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError):
assert_path_inside_upload_dirs(str(f), [allowed])
def test_raises_not_found_when_missing(self, tmp_path: Path):
allowed = tmp_path
missing = tmp_path / "does_not_exist.txt"
with pytest.raises(SDKFileNotFoundError) as excinfo:
assert_path_inside_upload_dirs(str(missing), [allowed])
assert "does not exist on disk" in str(excinfo.value)
assert "file_upload_dirs" in str(excinfo.value)
def test_empty_allowlist_fails_closed(self, tmp_path: Path):
f = tmp_path / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
assert_path_inside_upload_dirs(str(f), [])
msg = str(excinfo.value)
assert "no upload directories are configured" in msg
assert "dangerously_allow_auto_upload_download_files" in msg
def test_error_message_lists_allowlist(self, tmp_path: Path):
allowed = tmp_path / "inside"
outside = tmp_path / "outside"
allowed.mkdir()
outside.mkdir()
f = outside / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError) as excinfo:
assert_path_inside_upload_dirs(str(f), [allowed])
msg = str(excinfo.value)
assert str(allowed) in msg
assert "file_upload_dirs" in msg
assert "dangerously_allow_auto_upload_download_files" in msg
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only symlink semantics")
def test_rejects_symlink_pointing_outside(self, tmp_path: Path):
allowed = tmp_path / "inside"
outside = tmp_path / "outside"
allowed.mkdir()
outside.mkdir()
secret = outside / "secret.txt"
secret.write_text("pw")
link = allowed / "link.txt"
os.symlink(secret, link)
with pytest.raises(FileUploadPathNotAllowedError):
assert_path_inside_upload_dirs(str(link), [allowed])
class TestToolsWiresAllowlistCorrectly:
"""Verifies ``Tools`` forwards the right allowlist to ``FileHelper`` given
the full flag/value matrix."""
@staticmethod
def _make_tools(
dangerously_allow_auto_upload_download_files: bool,
file_upload_dirs,
):
from composio.core.models.tools import Tools
from unittest.mock import Mock
return Tools(
client=Mock(),
provider=Mock(name="provider"),
dangerously_allow_auto_upload_download_files=dangerously_allow_auto_upload_download_files,
file_upload_dirs=file_upload_dirs,
)
def test_flag_off_means_no_allowlist_is_ever_built(self):
# Flag off ⇒ auto-upload code path is never run. We pass ``None`` to
# FileHelper so manual/no-op paths don't accidentally enforce anything.
t = self._make_tools(False, None)
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
def test_flag_off_ignores_file_upload_dirs_entirely(self):
t = self._make_tools(False, ["/tmp/whatever"])
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
t = self._make_tools(False, False)
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
t = self._make_tools(False, [])
assert t._file_helper._file_upload_allowlist is None # noqa: SLF001
def test_flag_on_none_uses_default(self):
t = self._make_tools(True, None)
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
get_default_upload_dir()
]
def test_flag_on_false_means_empty_allowlist(self):
t = self._make_tools(True, False)
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
def test_flag_on_empty_list_means_empty_allowlist(self):
t = self._make_tools(True, [])
assert t._file_helper._file_upload_allowlist == [] # noqa: SLF001
def test_flag_on_explicit_dirs_replace_default(self, tmp_path: Path):
t = self._make_tools(True, [str(tmp_path)])
assert t._file_helper._file_upload_allowlist == [ # noqa: SLF001
tmp_path.resolve()
]
class TestFileUploadableAllowlistWiring:
"""Sanity: FileUploadable.from_path must honor the allowlist param."""
def test_from_path_with_allowlist_blocks_outside(self, tmp_path: Path):
from composio.core.models._files import FileUploadable
from unittest.mock import Mock
inside = tmp_path / "inside"
outside = tmp_path / "outside"
inside.mkdir()
outside.mkdir()
f = outside / "a.txt"
f.write_text("hi")
with pytest.raises(FileUploadPathNotAllowedError):
FileUploadable.from_path(
client=Mock(),
file=str(f),
tool="T",
toolkit="tk",
file_upload_allowlist=[inside],
)
def test_from_path_without_allowlist_skips_check(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Passing ``file_upload_allowlist=None`` must NOT trigger the check.
This simulates manual upload paths and the no-auto-upload default.
"""
from composio.core.models import _files as files_mod
from composio.core.models._files import FileUploadable
from unittest.mock import Mock
f = tmp_path / "a.txt"
f.write_text("hi")
# Short-circuit the network-bound parts.
mock_client = Mock()
mock_client.post.return_value = Mock(
new_presigned_url="https://example/upload", key="k"
)
monkeypatch.setattr(files_mod, "upload", lambda url, file, mimetype=None: True)
result = FileUploadable.from_path(
client=mock_client,
file=str(f),
tool="T",
toolkit="tk",
file_upload_allowlist=None,
)
assert result.s3key == "k"