From be65e047b0e64b216420ba38bec7a087409552ff Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 27 Oct 2026 22:21:48 +0800 Subject: [PATCH] fix(plugin): support mirrors for Release asset downloads (#7224) --- app/adapters/external/plugin/client.py | 73 +++++- app/adapters/system/plugin/package.py | 69 ++++- .../resources/system_settings_catalog.json | 12 + app/runtime/config.py | 8 + tests/test_plugin_helper.py | 4 + tests/test_plugin_release_transport.py | 235 ++++++++++++++++++ 6 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 tests/test_plugin_release_transport.py diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index c230c1b25..80f141144 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -5,11 +5,13 @@ from __future__ import annotations import asyncio +import io import json import re import threading import time import traceback +import zipfile from collections import Counter from concurrent.futures import Future from pathlib import Path @@ -100,6 +102,20 @@ def _format_request_error(error: Exception) -> str: return f"{type(error).__name__}: {detail}" if detail else type(error).__name__ +def _is_release_archive_response(response: Any) -> bool: + """ + 确认镜像返回 ZIP,避免将 HTTP 错误或下载提示页当作发布资产。 + + :param response: 已读取响应体的镜像响应 + :return: 响应成功且包含 ZIP 目录结构时返回 True + """ + return ( + response is not None + and response.status_code == 200 + and zipfile.is_zipfile(io.BytesIO(response.content)) + ) + + def build_local_repo_url( plugin_id: str, *, @@ -1507,17 +1523,28 @@ class PluginMarketTransport(metaclass=WeakSingleton): headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> list[tuple[str, str, PluginRequestOptions]]: """ 构造同步与异步 GitHub 请求共用的镜像和单一出口顺序。 + + :param url: 原始 GitHub 地址,保留给直连或 HTTP 代理出口 + :param headers: 原始出口请求头,不会复制到 Release 资产镜像 + :param timeout: 每个出口请求的超时秒数 + :param is_api: 普通 API 不使用静态资源镜像 + :param mirror_url: Release 资产的公开下载地址;提供时先匿名访问其镜像 + :return: 可选镜像与单一原始出口的有序列表 """ strategies: list[tuple[str, str, PluginRequestOptions]] = [] - if not is_api and get_runtime_setting('GITHUB_PROXY'): + content_url = mirror_url or (None if is_api else url) + if content_url and get_runtime_setting('GITHUB_PROXY'): proxy_url = ( - f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{url}" + f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{content_url}" ) + # 发布资产镜像只读取公开文件,不能接收 API Token、Cookie 等凭据。 + mirror_headers = {"Accept": "application/octet-stream"} if mirror_url else headers strategies.append( - ("镜像站", proxy_url, {"headers": headers, "timeout": timeout}) + ("镜像站", proxy_url, {"headers": mirror_headers, "timeout": timeout}) ) if get_runtime_setting('PROXY_HOST'): strategies.append( @@ -1541,7 +1568,8 @@ class PluginMarketTransport(metaclass=WeakSingleton): def __request_with_fallback(url: str, headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, - is_api: bool = False) -> Optional[Response]: + is_api: bool = False, + mirror_url: Optional[str] = None) -> Optional[Response]: """ 使用自动降级策略,请求资源:可选镜像站后只使用一个出口; 显式配置代理时不再追加无效直连。 @@ -1549,7 +1577,8 @@ class PluginMarketTransport(metaclass=WeakSingleton): :param url: 目标URL :param headers: 请求头信息 :param timeout: 请求超时时间 - :param is_api: 是否为GitHub API请求,API请求不走镜像站 + :param is_api: 普通 API 跳过镜像;Release 资产可额外提供公开下载地址 + :param mirror_url: 可选的公开 Release 资产地址,镜像请求不携带原始请求头 :return: 请求成功则返回 Response,失败返回 None """ strategies = PluginMarketTransport._build_github_request_strategies( @@ -1557,6 +1586,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): headers=headers, timeout=timeout, is_api=is_api, + mirror_url=mirror_url, ) # 遍历策略并尝试请求 @@ -1565,6 +1595,9 @@ class PluginMarketTransport(metaclass=WeakSingleton): try: res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True) + if mirror_url and strategy_name == "镜像站" and not _is_release_archive_response(res): + logger.warning("[GitHub] 镜像未返回有效 Release 压缩包,继续使用原始出口") + continue logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}") return res except Exception as e: @@ -1583,12 +1616,15 @@ class PluginMarketTransport(metaclass=WeakSingleton): headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Optional[Response]: """ 向包来源客户端公开既有同步 GitHub 降级传输。 + + :param mirror_url: 可选的公开 Release 资产地址,失败后保留原 API 出口 """ return PluginMarketTransport.__request_with_fallback( - url, headers=headers, timeout=timeout, is_api=is_api + url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url ) def __get_plugin_meta( @@ -1671,7 +1707,8 @@ class PluginMarketTransport(metaclass=WeakSingleton): async def __async_request_with_fallback(url: str, headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, - is_api: bool = False) -> Optional[httpx2.Response]: + is_api: bool = False, + mirror_url: Optional[str] = None) -> Optional[httpx2.Response]: """ 使用自动降级策略,异步请求资源:可选镜像站后只使用一个出口; 显式配置代理时不再追加无效直连。 @@ -1679,7 +1716,8 @@ class PluginMarketTransport(metaclass=WeakSingleton): :param url: 目标URL :param headers: 请求头信息 :param timeout: 请求超时时间 - :param is_api: 是否为GitHub API请求,API请求不走镜像站 + :param is_api: 普通 API 跳过镜像;Release 资产可额外提供公开下载地址 + :param mirror_url: 可选的公开 Release 资产地址,镜像请求不携带原始请求头 :return: 请求成功则返回 Response,失败返回 None """ strategies = PluginMarketTransport._build_github_request_strategies( @@ -1687,6 +1725,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): headers=headers, timeout=timeout, is_api=is_api, + mirror_url=mirror_url, ) # 遍历策略并尝试请求 @@ -1695,6 +1734,9 @@ class PluginMarketTransport(metaclass=WeakSingleton): try: res = await AsyncRequestUtils(**request_params).get_res(url=target_url, raise_exception=True) + if mirror_url and strategy_name == "镜像站" and not _is_release_archive_response(res): + logger.warning("[GitHub] 镜像未返回有效 Release 压缩包,继续使用原始出口") + continue logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}") return res except Exception as e: @@ -1713,12 +1755,15 @@ class PluginMarketTransport(metaclass=WeakSingleton): headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Optional[httpx2.Response]: """ 向包来源客户端公开既有异步 GitHub 降级传输。 + + :param mirror_url: 可选的公开 Release 资产地址,失败后保留原 API 出口 """ return await PluginMarketTransport.__async_request_with_fallback( - url, headers=headers, timeout=timeout, is_api=is_api + url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url ) @cached(maxsize=256, ttl=300, skip_none=False, local_only=True) @@ -2332,12 +2377,15 @@ class PluginPackageSourceClient: headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Any: """ 同步读取 GitHub 元数据或制品。 + + :param mirror_url: 可选的公开 Release 资产地址,镜像使用匿名请求 """ return self._transport.request_with_fallback( - url, headers=headers, timeout=timeout, is_api=is_api + url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url ) async def async_request( @@ -2347,12 +2395,15 @@ class PluginPackageSourceClient: headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Any: """ 异步读取 GitHub 元数据或制品。 + + :param mirror_url: 可选的公开 Release 资产地址,镜像使用匿名请求 """ return await self._transport.async_request_with_fallback( - url, headers=headers, timeout=timeout, is_api=is_api + url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url ) diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index 82b7ec42a..b545877c7 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -16,6 +16,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any, Optional, Protocol, Sequence, cast +from urllib.parse import quote import aiofiles import aioshutil @@ -244,6 +245,7 @@ class PluginPackageSourcePort(Protocol): headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Any: """ 同步读取 GitHub 元数据或制品。 @@ -251,7 +253,8 @@ class PluginPackageSourcePort(Protocol): :param url: GitHub 元数据或制品地址 :param headers: 附加请求头;None 使用请求适配器默认值 :param timeout: 每次请求超时,单位为秒,默认 60;None 沿用传输实现约定 - :param is_api: 是否为 API 请求;True 时不尝试静态资源镜像 + :param is_api: 是否为 API 请求;True 且未提供公开资产地址时跳过镜像 + :param mirror_url: 可选的公开 Release 资产地址,镜像匿名下载失败后回退原始地址 :return: 已读取内容的响应;所有传输策略均失败时返回 None """ @@ -262,6 +265,7 @@ class PluginPackageSourcePort(Protocol): headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Any: """ 异步读取 GitHub 元数据或制品。 @@ -269,7 +273,8 @@ class PluginPackageSourcePort(Protocol): :param url: GitHub 元数据或制品地址 :param headers: 附加请求头;None 使用请求适配器默认值 :param timeout: 每次请求超时,单位为秒,默认 60;None 沿用传输实现约定 - :param is_api: 是否为 API 请求;True 时不尝试静态资源镜像 + :param is_api: 是否为 API 请求;True 且未提供公开资产地址时跳过镜像 + :param mirror_url: 可选的公开 Release 资产地址,镜像匿名下载失败后回退原始地址 :return: 已读取内容的响应;所有传输策略均失败时返回 None """ @@ -1049,12 +1054,15 @@ class PluginPackageManager: headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Any: """ 通过外部来源客户端同步读取远端内容。 + + :param mirror_url: 可选的公开 Release 资产地址,由来源客户端隔离镜像凭据 """ return self._require_source().request( - url, headers=headers, timeout=timeout, is_api=is_api + url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url ) async def __async_request_with_fallback( @@ -1063,12 +1071,15 @@ class PluginPackageManager: headers: Optional[dict[str, str]] = None, timeout: Optional[int] = 60, is_api: bool = False, + mirror_url: Optional[str] = None, ) -> Any: """ 通过外部来源客户端异步读取远端内容。 + + :param mirror_url: 可选的公开 Release 资产地址,由来源客户端隔离镜像凭据 """ return await self._require_source().async_request( - url, headers=headers, timeout=timeout, is_api=is_api + url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url ) @staticmethod @@ -1081,10 +1092,16 @@ class PluginPackageManager: ) -> tuple[Optional[PluginReleaseInstallPlan], str]: """ 使用纯领域规则选择 Release 或文件列表安装计划。 + + 文件列表偏好只影响默认安装,显式选择的 Release 仍按指定版本安装。 """ return build_plugin_release_install_plan( plugin_id=pid, - metadata=meta, + metadata=( + meta + if release_version or not meta.get("release") or get_runtime_setting('PLUGIN_INSTALL_FROM_RELEASE') + else {**meta, "release": False} + ), release_version=release_version, release_items=release_items, current_version=get_app_version(), @@ -1354,6 +1371,9 @@ class PluginPackageManager: # 未声明 release 打包的插件继续使用文件列表方式安装。 def prepare_filelist(staging_dir: Path) -> tuple[bool, str]: + """ + 将当前索引对应的源码文件准备到暂存目录。 + """ return self.__prepare_content_via_filelist_sync( pid, selection.user_repo, @@ -2138,15 +2158,22 @@ class PluginPackageManager: 通过 GitHub Release 资产文件安装插件。 规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件; 将其全部解压到 dest_root,未指定时直接解压到插件运行目录。 + + :param pid: 插件标识 + :param user_repo: GitHub 仓库所有者及仓库名 + :param release_tag: 要安装的发布标签,同时用于确定规范资产名 + :param dest_root: 本次安装的暂存目录;None 使用插件运行目录 + :return: 安装是否成功及失败原因;镜像不可用时保留 API 下载 """ # 拼接资产文件名 asset_name = f"{release_tag.lower()}.zip" + timeout = get_runtime_setting('PLUGIN_RELEASE_TIMEOUT') release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}" rel_res = self.__request_with_fallback( release_api, headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo), - timeout=30, + timeout=timeout, is_api=True, ) if rel_res is None: @@ -2174,7 +2201,13 @@ class PluginPackageManager: # 使用资产的API端点下载,需要设置Accept头为application/octet-stream headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy() headers["Accept"] = "application/octet-stream" - res = self.__request_with_fallback(download_url, headers=headers, is_api=True) + mirror_url = ( + f"https://github.com/{user_repo}/releases/download/" + f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}" + ) + res = self.__request_with_fallback( + download_url, headers=headers, timeout=timeout, is_api=True, mirror_url=mirror_url, + ) if res is None or res.status_code != 200: return False, f"下载资产失败:{res.status_code if res else '连接失败'}" @@ -2535,6 +2568,9 @@ class PluginPackageManager: # 未声明 release 打包的插件继续使用文件列表方式安装。 async def prepare_filelist(staging_dir: Path) -> tuple[bool, str]: + """ + 将当前索引对应的源码文件异步准备到暂存目录。 + """ return await self.__prepare_content_via_filelist_async( pid, selection.user_repo, @@ -2733,15 +2769,22 @@ class PluginPackageManager: 通过 GitHub Release 资产文件安装插件(异步)。 规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件; 将其全部解压到 dest_root,未指定时直接解压到插件运行目录。 + + :param pid: 插件标识 + :param user_repo: GitHub 仓库所有者及仓库名 + :param release_tag: 要安装的发布标签,同时用于确定规范资产名 + :param dest_root: 本次安装的暂存目录;None 使用插件运行目录 + :return: 安装是否成功及失败原因;镜像不可用时保留 API 下载 """ # 拼接资产文件名 asset_name = f"{release_tag.lower()}.zip" + timeout = get_runtime_setting('PLUGIN_RELEASE_TIMEOUT') release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}" rel_res = await self.__async_request_with_fallback( release_api, headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo), - timeout=30, + timeout=timeout, is_api=True, ) if rel_res is None: @@ -2769,9 +2812,13 @@ class PluginPackageManager: # 使用资产的API端点下载,需要设置Accept头为application/octet-stream headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy() headers["Accept"] = "application/octet-stream" - res = await self.__async_request_with_fallback(download_url, - headers=headers, - is_api=True) + mirror_url = ( + f"https://github.com/{user_repo}/releases/download/" + f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}" + ) + res = await self.__async_request_with_fallback( + download_url, headers=headers, timeout=timeout, is_api=True, mirror_url=mirror_url, + ) if res is None or res.status_code != 200: return False, f"下载资产失败:{res.status_code if res else '连接失败'}" diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index 6121e1a84..c2631cc98 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -1213,6 +1213,12 @@ "source_file": "app/runtime/config.py", "source_line": 613 }, + "PLUGIN_INSTALL_FROM_RELEASE": { + "description": "插件默认优先安装 Release 资产;false 直接安装当前索引文件,显式选择版本不受影响", + "group": "plugins", + "source_file": "app/runtime/config.py", + "source_line": 633 + }, "PLUGIN_LOCAL_REPO_PATHS": { "description": "本地插件仓库目录,多个地址使用,分隔", "group": "plugins", @@ -1225,6 +1231,12 @@ "source_file": "app/runtime/config.py", "source_line": 607 }, + "PLUGIN_RELEASE_TIMEOUT": { + "description": "插件 Release 元数据和资产各出口的请求超时秒数,默认 30,范围 1 至 300", + "group": "plugins", + "source_file": "app/runtime/config.py", + "source_line": 637 + }, "PLUGIN_STATISTIC_SHARE": { "description": "插件安装数据共享", "group": "plugins", diff --git a/app/runtime/config.py b/app/runtime/config.py index 9843642f6..41e5e4b13 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -630,6 +630,14 @@ class ConfigModel(BaseModel): PLUGIN_STATISTIC_SHARE: bool = Field(default=True, description='插件安装数据共享') USAGE_STATISTIC_SHARE: bool = Field(default=True, description='安装版本统计上报') PLUGIN_AUTO_RELOAD: bool = Field(default=False, description='是否开启插件热加载') + PLUGIN_INSTALL_FROM_RELEASE: bool = Field( + default=True, + description='插件默认优先安装 Release 资产;false 直接安装当前索引文件,显式选择版本不受影响', + ) + PLUGIN_RELEASE_TIMEOUT: int = Field( + default=30, ge=1, le=300, + description='插件 Release 元数据和资产各出口的请求超时秒数,默认 30,范围 1 至 300', + ) DEPRECATION_ENABLED: Optional[str] = Field(default=None, description='临时放行的废弃标识,多个用,分隔;仅对已进入停用阶段的接口有效,用于观察真实依赖方') PLUGIN_LOCAL_REPO_PATHS: Optional[str] = Field(default=None, description='本地插件仓库目录,多个地址使用,分隔') diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 8a985d37f..99b2dcaa2 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -208,6 +208,7 @@ def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None: values = SimpleNamespace( ROOT_PATH=tmp_path, REPO_GITHUB_HEADERS=lambda repo=None: {}, + PLUGIN_RELEASE_TIMEOUT=30, ) monkeypatch.setattr( "app.adapters.system.plugin.package.get_runtime_setting", @@ -218,6 +219,9 @@ def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None: safe_root = tmp_path.resolve() def guarded_mkdir(path: Path, *args, **kwargs): + """ + 拒绝在测试临时目录以外创建插件文件夹。 + """ try: path.resolve().relative_to(safe_root) except ValueError as exc: diff --git a/tests/test_plugin_release_transport.py b/tests/test_plugin_release_transport.py new file mode 100644 index 000000000..299bf7a2f --- /dev/null +++ b/tests/test_plugin_release_transport.py @@ -0,0 +1,235 @@ +""" +验证插件 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"download unavailable" 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