Files
MoviePilot/tests/test_plugin_release_transport.py

236 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证插件 Release 下载出口、凭据隔离和安装方式配置。
"""
import io
import zipfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from pydantic import ValidationError
from app.adapters.external.plugin import client
from app.adapters.external.plugin.client import PluginPackageSourceClient
from app.adapters.system.plugin import package
from app.adapters.system.plugin.package import PluginPackageManager
from app.runtime.config import ConfigModel
def _archive() -> bytes:
"""
生成可安装的插件 ZIP,不访问网络或运行目录。
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("__init__.py", "plugin_payload = True\n")
return buffer.getvalue()
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("mirror_result", ["success", "timeout", "http_error", "html"])
@pytest.mark.parametrize("use_proxy", [False, True])
@pytest.mark.parametrize("authenticated", [False, True])
async def test_release_install_uses_anonymous_mirror_then_authenticated_api(
monkeypatch, tmp_path, asynchronous, mirror_result, use_proxy, authenticated,
):
"""
镜像只收到公开下载地址与无凭据请求头,失败后由原 API 出口接管。
"""
headers = {"Authorization": "Bearer test-token", "Cookie": "test-cookie"} if authenticated else {}
proxy = {"https": "http://proxy.example:7890"}
settings = {
"GITHUB_PROXY": "https://mirror.example",
"PROXY_HOST": "http://proxy.example:7890" if use_proxy else None,
"PROXY": proxy if use_proxy else None,
"PLUGIN_RELEASE_TIMEOUT": 7,
"REPO_GITHUB_HEADERS": lambda **_kwargs: headers.copy(),
}
for module in (client, package):
monkeypatch.setattr(
module, "get_runtime_setting", lambda key, default=None: settings.get(key, default),
)
requests = []
archive = _archive()
def send(options, *, url, raise_exception):
"""
回放元数据、镜像故障及 API 二进制下载,并记录真实出口参数。
"""
assert raise_exception is True
requests.append((url, options))
if "/releases/tags/" in url:
return SimpleNamespace(
status_code=200,
json=lambda: {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 123}]},
)
if url.startswith("https://mirror.example/"):
if mirror_result == "timeout":
raise TimeoutError("mirror unavailable")
return SimpleNamespace(
status_code=503 if mirror_result == "http_error" else 200,
content=b"<html>download unavailable</html>" if mirror_result == "html" else archive,
)
return SimpleNamespace(status_code=200, content=archive)
def request_factory(**options):
"""
为同步或异步适配器提供相同的离线响应序列。
"""
request_mock = AsyncMock if asynchronous else Mock
return SimpleNamespace(
get_res=request_mock(side_effect=lambda **kwargs: send(options, **kwargs)),
)
monkeypatch.setattr(client, "AsyncRequestUtils" if asynchronous else "RequestUtils", request_factory)
manager = PluginPackageManager(source=PluginPackageSourceClient(), plugin_root=tmp_path)
if asynchronous:
result = await manager.async_install_from_release("DemoPlugin", "owner/repo", "DemoPlugin_v1.2.3")
else:
result = manager.install_from_release("DemoPlugin", "owner/repo", "DemoPlugin_v1.2.3")
assert result == (True, "")
assert (tmp_path / "demoplugin" / "__init__.py").read_text() == "plugin_payload = True\n"
assert requests[0] == (
"https://api.github.com/repos/owner/repo/releases/tags/DemoPlugin_v1.2.3",
{"headers": headers, "timeout": 7, **({"proxies": proxy} if use_proxy else {})},
)
assert requests[1] == (
"https://mirror.example/https://github.com/owner/repo/releases/download/"
"DemoPlugin_v1.2.3/demoplugin_v1.2.3.zip",
{"headers": {"Accept": "application/octet-stream"}, "timeout": 7},
)
if mirror_result == "success":
assert len(requests) == 2
else:
assert requests[2:] == [(
"https://api.github.com/repos/owner/repo/releases/assets/123",
{
"headers": {**headers, "Accept": "application/octet-stream"},
"timeout": 7,
**({"proxies": proxy} if use_proxy else {}),
},
)]
assert headers == ({"Authorization": "Bearer test-token", "Cookie": "test-cookie"} if authenticated else {})
@pytest.mark.parametrize("use_proxy", [False, True])
def test_unconfigured_mirror_preserves_authenticated_asset_api(monkeypatch, use_proxy):
"""
未配置镜像时,带令牌的资产 API 只使用原有代理或直连出口。
"""
proxy = {"https": "http://proxy.example:7890"}
settings = {"GITHUB_PROXY": "", "PROXY_HOST": "proxy" if use_proxy else None, "PROXY": proxy}
monkeypatch.setattr(client, "get_runtime_setting", lambda key: settings[key])
headers = {"authorization": "Bearer private-token", "Accept": "application/octet-stream"}
api_url = "https://api.github.com/repos/owner/private/releases/assets/123"
strategies = client.PluginMarketTransport._build_github_request_strategies(
api_url, headers=headers, timeout=7, is_api=True,
mirror_url="https://github.com/owner/private/releases/download/v1/plugin.zip",
)
assert strategies == [(
"代理" if use_proxy else "直连", api_url,
{"headers": headers, "timeout": 7, **({"proxies": proxy} if use_proxy else {})},
)]
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_filelist_preference_skips_release_requests(monkeypatch, tmp_path, asynchronous):
"""
关闭默认 Release 安装后,同步与异步入口都直接准备索引文件。
"""
settings = {"PLUGIN_INSTALL_FROM_RELEASE": False, "VERSION_FLAG": "v3"}
monkeypatch.setattr(package, "get_runtime_setting", lambda key: settings[key])
source = Mock()
source.is_local_repo_url.return_value = False
source.get_repo_info.return_value = ("owner", "repo")
source.get_plugin_package_version.return_value = "v3"
metadata = {"DemoPlugin": {"release": True, "version": "1.2.3"}}
source.get_plugins.return_value = metadata
source.async_get_plugin_package_version = AsyncMock(return_value="v3")
source.async_get_plugins = AsyncMock(return_value=metadata)
manager = PluginPackageManager(source=source, plugin_root=tmp_path)
prepare = AsyncMock(return_value=(True, "")) if asynchronous else Mock(return_value=(True, ""))
monkeypatch.setattr(
manager, "_PluginPackageManager__prepare_content_via_filelist_async" if asynchronous
else "_PluginPackageManager__prepare_content_via_filelist_sync", prepare,
)
async def async_flow(_pid, _force, prepare_content, *_args):
"""
执行异步内容准备,保留本用例关注的安装分支选择。
"""
return await prepare_content(tmp_path)
monkeypatch.setattr(
manager, "_PluginPackageManager__install_flow_async" if asynchronous
else "_PluginPackageManager__install_flow_sync",
async_flow if asynchronous else lambda _pid, _force, prepare_content, *_args: prepare_content(tmp_path),
)
if asynchronous:
result = await manager.async_install_raw("DemoPlugin", "https://github.com/owner/repo")
prepare.assert_awaited_once()
else:
result = manager.install_raw("DemoPlugin", "https://github.com/owner/repo")
prepare.assert_called_once()
assert result == (True, "")
source.request.assert_not_called()
source.async_request.assert_not_called()
source.get_plugin_release_versions.assert_not_called()
source.async_get_plugin_release_versions.assert_not_called()
@pytest.mark.parametrize("prefer_release", [False, True])
@pytest.mark.parametrize("release_version", [None, "1.2.0"])
def test_filelist_preference_preserves_explicit_release(monkeypatch, prefer_release, release_version):
"""
文件列表偏好跳过默认 Release,但用户指定版本时仍保持历史版本准入。
"""
monkeypatch.setattr(
package, "get_runtime_setting", lambda key: prefer_release if key == "PLUGIN_INSTALL_FROM_RELEASE" else None,
)
selection, message = PluginPackageManager._select_remote_install(
pid="DemoPlugin", user_repo="owner/repo", package_version="v3",
meta={"release": True, "version": "1.2.3"},
release_version=release_version, release_items=[{"version": "1.2.0"}],
)
assert message == ""
assert selection is not None
expected = f"DemoPlugin_v{release_version or '1.2.3'}" if prefer_release or release_version else None
assert selection.release_tag == expected
assert selection.fallback_to_filelist is (prefer_release and release_version is None)
def test_release_configuration_defaults_and_bounds():
"""
默认保持 Release 优先,超时支持字符串环境值并拒绝无效范围。
"""
defaults = ConfigModel()
assert defaults.PLUGIN_INSTALL_FROM_RELEASE is True
assert defaults.PLUGIN_RELEASE_TIMEOUT == 30
configured = ConfigModel(PLUGIN_INSTALL_FROM_RELEASE="false", PLUGIN_RELEASE_TIMEOUT="7")
assert configured.PLUGIN_INSTALL_FROM_RELEASE is False
assert configured.PLUGIN_RELEASE_TIMEOUT == 7
for value in (0, -1, 301):
with pytest.raises(ValidationError):
ConfigModel(PLUGIN_RELEASE_TIMEOUT=value)
def test_release_configuration_is_registered_in_system_settings():
"""
新配置须同时进入系统设置目录,避免 API 在加载配置合同时失败。
"""
from app.application.settings.contract import CORE_SETTING_SPECS, build_value_schema
preference = CORE_SETTING_SPECS["PLUGIN_INSTALL_FROM_RELEASE"]
timeout = CORE_SETTING_SPECS["PLUGIN_RELEASE_TIMEOUT"]
assert preference.group == timeout.group == "plugins"
assert preference.examples == (True,)
assert timeout.examples == (30,)
assert timeout.unit == "seconds"
assert build_value_schema(preference)["type"] == "boolean"
assert build_value_schema(timeout)["minimum"] == 1
assert build_value_schema(timeout)["maximum"] == 300