Files
MoviePilot/tests/test_download_save_path_allowlist.py
2026-07-05 09:31:01 +08:00

440 lines
13 KiB
Python

import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import app.agent.tools.impl.add_download_tasks as add_tasks_module
import app.agent.tools.impl.update_download_tasks as update_tasks_module
import app.chain.download as download_module
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
from app.chain.download import DownloadChain
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.helper.directory import validate_download_save_path
from app.schemas import DownloaderTorrent, TransferDirectoryConf
from app.schemas.types import MediaType
def _download_dirs():
return [
TransferDirectoryConf(
name="本地下载",
priority=1,
storage="local",
download_path="/downloads",
),
TransferDirectoryConf(
name="动漫远程下载",
priority=2,
storage="rclone",
download_path="/media/anime",
),
]
def _windows_download_dirs():
return [
TransferDirectoryConf(
name="Windows 下载",
priority=1,
storage="local",
download_path="C:/downloads",
),
]
@pytest.fixture(autouse=True)
def patch_download_dirs(monkeypatch):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
lambda _self: _download_dirs(),
)
@pytest.mark.parametrize(
("save_path", "expected"),
[
("/downloads", "/downloads"),
("/downloads/movie/demo", "/downloads/movie/demo"),
("rclone:/media/anime/sub", "rclone:/media/anime/sub"),
],
)
def test_validate_download_save_path_accepts_configured_roots_and_children(save_path, expected):
assert validate_download_save_path(save_path) == expected
@pytest.mark.parametrize(
("save_path", "expected"),
[
("C:/downloads", "C:/downloads"),
("C:/downloads/movie", "C:/downloads/movie"),
],
)
def test_validate_download_save_path_accepts_windows_configured_root_and_children(
monkeypatch,
save_path,
expected,
):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
lambda _self: _windows_download_dirs(),
)
assert validate_download_save_path(save_path) == expected
@pytest.mark.parametrize(
"save_path",
[
"C:/other",
"D:/downloads",
"C:/downloads/../Windows",
"C:\\downloads\\movie",
"\\\\server\\share\\downloads",
],
)
def test_validate_download_save_path_rejects_windows_paths_outside_configured_root(
monkeypatch,
save_path,
):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
lambda _self: _windows_download_dirs(),
)
with pytest.raises(ValueError):
validate_download_save_path(save_path)
@pytest.mark.parametrize(
"save_path",
[
"/etc",
"/downloads/../etc",
"/downloads\\..\\etc",
"C:/downloads",
"\\\\server\\share\\downloads",
"//server/share/downloads",
"relative/downloads",
"",
" ",
"rclone:/media/movies",
"smb:/media/anime/sub",
],
)
def test_validate_download_save_path_rejects_paths_outside_configured_roots(save_path):
with pytest.raises(ValueError):
validate_download_save_path(save_path)
def _build_context() -> Context:
return Context(
meta_info=MetaInfo("Demo Movie 2026"),
media_info=MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
year="2026",
tmdb_id=1,
genre_ids=[18],
),
torrent_info=TorrentInfo(
title="Demo Movie 2026",
enclosure="https://example.test/demo.torrent",
site_cookie="uid=1",
site_name="TestSite",
),
)
def _build_download_chain() -> DownloadChain:
chain = DownloadChain.__new__(DownloadChain)
chain.download = MagicMock()
chain.post_message = MagicMock()
chain.messagehelper = MagicMock()
return chain
def test_download_single_rejects_bad_save_path_before_downloader(monkeypatch):
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
chain = _build_download_chain()
download_id, error_msg = chain.download_single(
context=_build_context(),
torrent_content=b"torrent-content",
save_path="/etc",
return_detail=True,
)
assert download_id is None
assert "保存路径" in error_msg
chain.download.assert_not_called()
def test_download_single_rejects_event_overridden_bad_save_path_before_downloader(monkeypatch):
event_data = SimpleNamespace(cancel=False, source="plugin", reason="", options={"save_path": "/etc"})
monkeypatch.setattr(
download_module.eventmanager,
"send_event",
lambda *args, **kwargs: SimpleNamespace(event_data=event_data),
)
chain = _build_download_chain()
download_id, error_msg = chain.download_single(
context=_build_context(),
torrent_content=b"torrent-content",
save_path="/downloads",
return_detail=True,
)
assert download_id is None
assert "保存路径" in error_msg
chain.download.assert_not_called()
@pytest.mark.parametrize("save_path", ["", " "])
def test_download_single_rejects_explicit_empty_save_path_before_default_fallback(monkeypatch, save_path):
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
monkeypatch.setattr(
download_module.DirectoryHelper,
"get_dir",
lambda *_args, **_kwargs: TransferDirectoryConf(storage="local", download_path="/downloads"),
)
chain = _build_download_chain()
download_id, error_msg = chain.download_single(
context=_build_context(),
torrent_content=b"torrent-content",
save_path=save_path,
return_detail=True,
)
assert download_id is None
assert "保存路径" in error_msg
chain.download.assert_not_called()
def test_download_single_rejects_event_empty_save_path_override_before_downloader(monkeypatch):
event_data = SimpleNamespace(cancel=False, source="plugin", reason="", options={"save_path": ""})
monkeypatch.setattr(
download_module.eventmanager,
"send_event",
lambda *args, **kwargs: SimpleNamespace(event_data=event_data),
)
chain = _build_download_chain()
download_id, error_msg = chain.download_single(
context=_build_context(),
torrent_content=b"torrent-content",
save_path="/downloads",
return_detail=True,
)
assert download_id is None
assert "保存路径" in error_msg
chain.download.assert_not_called()
def test_resolve_media_download_dir_rejects_bad_subtitle_save_path():
media_info = MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
year="2026",
tmdb_id=1,
)
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
media_info=media_info,
save_path="/etc",
)
assert storage is None
assert target_dir is None
assert error_msg == "保存路径不在允许的下载目录范围内"
def test_download_subtitle_returns_specific_error_for_bad_save_path():
chain = DownloadChain.__new__(DownloadChain)
chain.recognize_media = MagicMock(
return_value=MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
year="2026",
tmdb_id=1,
)
)
subtitle = SubtitleInfo(
title="Demo Movie",
enclosure="https://example.test/subtitle.srt",
)
success, message, saved_files = chain.download_subtitle(
subtitle=subtitle,
save_path="/etc",
)
assert not success
assert message == "保存路径不在允许的下载目录范围内"
assert saved_files == []
@pytest.mark.parametrize("save_path", ["", " "])
def test_resolve_media_download_dir_rejects_explicit_empty_save_path_before_default_fallback(
monkeypatch,
save_path,
):
monkeypatch.setattr(
download_module.DirectoryHelper,
"get_dir",
lambda *_args, **_kwargs: TransferDirectoryConf(storage="local", download_path="/downloads"),
)
media_info = MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
year="2026",
tmdb_id=1,
)
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
media_info=media_info,
save_path=save_path,
)
assert storage is None
assert target_dir is None
assert "保存路径" in error_msg
def test_add_download_tasks_direct_magnet_rejects_bad_save_path_before_downloader():
with pytest.raises(ValueError):
AddDownloadTasksTool._resolve_direct_download_dir("/etc")
@pytest.mark.parametrize("save_path", ["", " "])
def test_add_download_tasks_direct_magnet_rejects_explicit_empty_save_path_before_default_fallback(save_path):
with pytest.raises(ValueError):
AddDownloadTasksTool._resolve_direct_download_dir(save_path)
def test_add_download_tasks_cached_context_rejects_bad_save_path_before_download_single(monkeypatch):
download_chain = MagicMock()
monkeypatch.setattr(add_tasks_module, "DownloadChain", lambda: download_chain)
with pytest.raises(ValueError):
AddDownloadTasksTool._download_single_sync(
context=_build_context(),
downloader="qb",
save_path="/etc",
merged_labels=None,
)
download_chain.download_single.assert_not_called()
@pytest.mark.parametrize("save_path", ["", " "])
def test_add_download_tasks_cached_context_rejects_explicit_empty_save_path_before_download_single(
monkeypatch,
save_path,
):
download_chain = MagicMock()
monkeypatch.setattr(add_tasks_module, "DownloadChain", lambda: download_chain)
with pytest.raises(ValueError):
AddDownloadTasksTool._download_single_sync(
context=_build_context(),
downloader="qb",
save_path=save_path,
merged_labels=None,
)
download_chain.download_single.assert_not_called()
def test_update_download_tasks_rejects_bad_save_path_before_update_torrent(monkeypatch):
hash_value = "a" * 40
download_chain = MagicMock()
download_chain.list_torrents.return_value = [
DownloaderTorrent(downloader="qb", hash=hash_value, title="Demo")
]
monkeypatch.setattr(update_tasks_module, "DownloadChain", lambda: download_chain)
result = UpdateDownloadTasksTool._update_download_sync(
hash_value=hash_value,
save_path="/etc",
)
assert result["downloader"] == "qb"
assert result["results"] == [
{
"operation": "save_path",
"success": False,
"message": "保存目录不在允许的下载目录范围内",
}
]
download_chain.update_torrent.assert_not_called()
def test_update_download_tasks_passes_normalized_save_path_to_update_torrent(monkeypatch):
hash_value = "b" * 40
download_chain = MagicMock()
download_chain.list_torrents.return_value = [
DownloaderTorrent(downloader="qb", hash=hash_value, title="Demo")
]
download_chain.update_torrent.return_value = {"save_path": True}
monkeypatch.setattr(update_tasks_module, "DownloadChain", lambda: download_chain)
result = UpdateDownloadTasksTool._update_download_sync(
hash_value=hash_value,
save_path="rclone:/media/anime/sub",
)
assert result["results"][0]["success"] is True
download_chain.update_torrent.assert_called_once_with(
hash_string=hash_value,
downloader="qb",
download_limit=None,
upload_limit=None,
tracker_list=None,
save_path="rclone:/media/anime/sub",
category=None,
ratio_limit=None,
seeding_time_limit=None,
)
def test_add_download_tasks_run_rejects_bad_save_path_before_partial_download(monkeypatch):
tool = AddDownloadTasksTool(session_id="session-1", user_id="10001")
download_chain = MagicMock()
monkeypatch.setattr(add_tasks_module, "DownloadChain", lambda: download_chain)
result = asyncio.run(
tool.run(
torrent_url=["magnet:?xt=urn:btih:123"],
save_path="/etc",
)
)
assert "save_path" in result
download_chain.download.assert_not_called()
@pytest.mark.parametrize("save_path", ["", " "])
def test_add_download_tasks_run_rejects_explicit_empty_save_path_before_partial_download(
monkeypatch,
save_path,
):
tool = AddDownloadTasksTool(session_id="session-1", user_id="10001")
download_chain = MagicMock()
monkeypatch.setattr(add_tasks_module, "DownloadChain", lambda: download_chain)
result = asyncio.run(
tool.run(
torrent_url=["magnet:?xt=urn:btih:123"],
save_path=save_path,
)
)
assert "save_path" in result
download_chain.download.assert_not_called()