fix(plugin): support mirrors for Release asset downloads (#7224)

This commit is contained in:
jxxghp
2026-10-27 22:21:48 +08:00
committed by GitHub
parent d9afaba8dd
commit be65e047b0
6 changed files with 379 additions and 22 deletions

View File

@@ -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
)

View File

@@ -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 '连接失败'}"

View File

@@ -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",

View File

@@ -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='本地插件仓库目录,多个地址使用,分隔')

View File

@@ -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:

View File

@@ -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"<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