"""Unit tests for share_file tool."""
from urllib.parse import unquote, urlparse
import pytest
import navi.session_files as session_files_mod
import navi.tools.share_file as share_file_mod
from navi.config import Settings
from navi.tools._internal.base import ToolContext
from navi.tools.share_file import ShareFileTool
class TestShareFileTool:
@pytest.fixture
def tool(self, monkeypatch, tmp_path):
async def _to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(share_file_mod.asyncio, "to_thread", _to_thread)
_test_settings = Settings(
session_files_dir=str(tmp_path / "sessions"),
share_file_max_size_mb=1024,
public_url="http://localhost:8000",
)
monkeypatch.setattr(share_file_mod, "settings", _test_settings)
monkeypatch.setattr(session_files_mod, "settings", _test_settings)
yield ShareFileTool()
async def test_rejects_relative_path(self, tool):
result = await tool.execute({"path": "workspace/report.txt"}, ctx=ToolContext(session_id="sess 1"))
assert not result.success
assert result.error == "path_not_absolute"
async def test_copies_file_into_session_dir(self, tool, tmp_path):
src = tmp_path / "report.txt"
src.write_text("hello")
result = await tool.execute({"path": str(src), "filename": "clean report.txt"}, ctx=ToolContext(session_id="sess 1"))
assert result.success
dest = tmp_path / "sessions" / "sess 1" / "clean report.txt"
assert dest.read_text() == "hello"
assert result.metadata["filename"] == "clean report.txt"
async def test_rejects_files_over_share_limit(self, tool, monkeypatch, tmp_path):
_test_settings = Settings(
session_files_dir=str(tmp_path / "sessions"),
share_file_max_size_mb=0,
public_url="http://localhost:8000",
)
monkeypatch.setattr(share_file_mod, "settings", _test_settings)
monkeypatch.setattr(session_files_mod, "settings", _test_settings)
src = tmp_path / "too_large.bin"
src.write_bytes(b"x")
result = await tool.execute({"path": str(src)}, ctx=ToolContext(session_id="sess 1"))
assert not result.success
assert result.error == "file_too_large"
async def test_url_quotes_session_and_filename(self, tool, tmp_path):
src = tmp_path / "source.txt"
src.write_text("hello")
result = await tool.execute({"path": str(src), "filename": "отчёт #1.txt"}, ctx=ToolContext(session_id="sess 1"))
assert result.success
parsed = urlparse(result.metadata["url"])
assert parsed.path.endswith(
"/api/sessions/sess%201/files/%D0%BE%D1%82%D1%87%D1%91%D1%82%20%231.txt"
)
assert unquote(parsed.path).endswith("/api/sessions/sess 1/files/отчёт #1.txt")
async def test_duplicate_filename_gets_numbered_copy(self, tool, tmp_path):
src = tmp_path / "source.txt"
src.write_text("new")
existing = tmp_path / "sessions" / "sess 1" / "report.txt"
existing.parent.mkdir(parents=True)
existing.write_text("old")
result = await tool.execute({"path": str(src), "filename": "report.txt"}, ctx=ToolContext(session_id="sess 1"))
assert result.success
assert existing.read_text() == "old"
numbered = tmp_path / "sessions" / "sess 1" / "report_1.txt"
assert numbered.read_text() == "new"
assert result.metadata["filename"] == "report_1.txt"
class TestShareFileNonAdminSources:
"""A non-admin may share from the sandbox and from the current session dir.
The session directory is where uploads land, so a file the user just sent
has to be shareable from there — otherwise the agent must smuggle it into
its sandbox first, which is exactly the bypass the security policy forbids.
"""
@pytest.fixture
def tool(self, monkeypatch, tmp_path):
async def _to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(share_file_mod.asyncio, "to_thread", _to_thread)
_test_settings = Settings(
session_files_dir=str(tmp_path / "sessions"),
share_file_max_size_mb=1024,
public_url="http://localhost:8000",
)
monkeypatch.setattr(share_file_mod, "settings", _test_settings)
monkeypatch.setattr(session_files_mod, "settings", _test_settings)
# The sandbox is relative to the process cwd: user_data/<uid>/.
monkeypatch.chdir(tmp_path)
yield ShareFileTool()
@staticmethod
def _ctx(session_id="sess 1", user_id="7"):
return ToolContext(session_id=session_id, user_id=user_id, user_role="user")
async def test_shares_upload_from_session_dir_in_place(self, tool, tmp_path):
session = tmp_path / "sessions" / "sess 1"
session.mkdir(parents=True)
src = session / "Project.wav"
src.write_text("audio")
result = await tool.execute({"path": str(src)}, ctx=self._ctx())
assert result.success
# Copied onto itself, not duplicated into Project_1.wav.
assert result.metadata["filename"] == "Project.wav"
assert sorted(p.name for p in session.iterdir()) == ["Project.wav"]
async def test_shares_from_sandbox(self, tool, tmp_path):
sandbox = tmp_path / "user_data" / "7"
sandbox.mkdir(parents=True)
src = sandbox / "report.txt"
src.write_text("data")
result = await tool.execute({"path": str(src)}, ctx=self._ctx())
assert result.success
assert (tmp_path / "sessions" / "sess 1" / "report.txt").read_text() == "data"
async def test_denies_source_outside_both_roots(self, tool, tmp_path):
outside = tmp_path / "elsewhere.txt"
outside.write_text("x")
result = await tool.execute({"path": str(outside)}, ctx=self._ctx())
assert not result.success
assert result.error == "access_denied"
# The message names both roots, so the agent can retry in the right one.
assert str(tmp_path / "user_data" / "7") in result.output
assert str(tmp_path / "sessions" / "sess 1") in result.output
async def test_relative_path_still_resolves_into_sandbox(self, tool, tmp_path):
sandbox = tmp_path / "user_data" / "7"
sandbox.mkdir(parents=True)
(sandbox / "notes.txt").write_text("n")
result = await tool.execute({"path": "notes.txt"}, ctx=self._ctx())
assert result.success
assert (tmp_path / "sessions" / "sess 1" / "notes.txt").read_text() == "n"