Compare commits

...

6 Commits

Author SHA1 Message Date
jxxghp
d6db0a86f6 fix llm test message 2026-06-13 23:20:15 +08:00
jxxghp
6e8bce3d04 fix: 优化LLM测试基础地址错误提示 2026-06-13 23:02:21 +08:00
jxxghp
ed1e31d379 fix: 兼容插件仪表盘空返回 2026-06-13 22:54:35 +08:00
jxxghp
3a233014de docs: update moviepilot plugin creation skill 2026-06-13 22:39:18 +08:00
InfinityPacer
13cb1683ff fix: restrict log access and add zip download (#5936) 2026-06-13 20:20:10 +08:00
jxxghp
ac9132cba6 fix bcrypt 2026-06-13 20:16:04 +08:00
9 changed files with 578 additions and 18 deletions

View File

@@ -53,7 +53,7 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
清理错误信息中的敏感字段,避免回显密钥。
"""
if not message:
return "LLM 调用失败"
return "LLM 没有返回任何内容"
sanitized = message
if api_key:
@@ -68,6 +68,14 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
"Authorization: ***",
sanitized,
)
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
if "str" in normalized_message and "modeldump" in normalized_message:
return (
"服务返回内容不是兼容的模型响应,"
"请检查基础地址是否填写为 API Base URL不要填写网页地址或完整的 "
"chat/completions 路径"
)
return sanitized

View File

@@ -513,7 +513,7 @@ def plugin_dashboard(
plugin_id: str,
user_agent: Annotated[str | None, Header()] = None,
_: User = Depends(get_current_active_superuser),
) -> schemas.PluginDashboard:
) -> Optional[schemas.PluginDashboard]:
"""
根据插件ID获取插件仪表板
"""

View File

@@ -1,7 +1,11 @@
import asyncio
import io
import json
import re
import zipfile
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Union, Annotated
from urllib.parse import urljoin, urlparse
@@ -62,6 +66,8 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
)
}
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
_LOG_DOWNLOAD_LIMIT = 10
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -285,6 +291,98 @@ def _build_nettest_rules() -> list[dict[str, Any]]:
return rules
def _collect_named_log_files(name: str) -> list[Path]:
"""
根据前端传入的日志标识收集可下载日志文件。
`moviepilot` 固定表示主程序日志,其余标识按插件 ID 处理并映射到
`plugins/<plugin_id>.log*`。这里不接收路径或后缀,避免下载入口变成任意
日志文件选择器;滚动日志按当前文件优先、备份文件按修改时间倒序补足。
"""
normalized_name = (name or "").strip().lower()
if not normalized_name or not _LOG_DOWNLOAD_NAME_PATTERN.fullmatch(normalized_name):
raise HTTPException(status_code=404, detail="Not Found")
log_root = settings.LOG_PATH
if normalized_name == "moviepilot":
log_dir = log_root
log_prefix = "moviepilot.log"
else:
log_dir = log_root / "plugins"
log_prefix = f"{normalized_name}.log"
if not log_dir.exists() or not log_dir.is_dir():
raise HTTPException(status_code=404, detail="Not Found")
current_log = log_dir / log_prefix
backup_logs = [
item
for item in log_dir.iterdir()
if item.is_file() and item.name.startswith(f"{log_prefix}.")
]
backup_logs.sort(key=lambda item: item.stat().st_mtime, reverse=True)
log_files = []
if current_log.exists() and current_log.is_file():
log_files.append(current_log)
log_files.extend(backup_logs)
return log_files[:_LOG_DOWNLOAD_LIMIT]
def _verify_log_resource_superuser(
token_payload: schemas.TokenPayload = Depends(verify_resource_token),
) -> schemas.TokenPayload:
"""
校验日志资源访问权限。
日志接口通过浏览器新窗口和 EventSource 访问,不能依赖普通 API 请求头;
因此这里复用资源 Cookie 完成身份识别,再额外要求管理员身份,避免普通
登录用户读取可能包含敏感信息的日志。
"""
if not token_payload.super_user:
raise HTTPException(status_code=403, detail="用户权限不足")
return token_payload
async def _build_log_zip_response(name: str) -> StreamingResponse:
"""
将指定日志标识对应的日志文件打包为 zip 响应。
打包前逐个校验文件仍位于日志根目录内,避免符号链接或并发文件变更绕过
`name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分
主程序日志与插件日志。
"""
log_files = _collect_named_log_files(name)
if not log_files:
raise HTTPException(status_code=404, detail="Not Found")
log_root = settings.LOG_PATH
async_log_root = AsyncPath(log_root)
zip_buffer = io.BytesIO()
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
safe_name = (name or "logs").strip().lower() or "logs"
zip_stem = f"{safe_name}-logs-{filename_time}"
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for log_file in log_files:
if not await SecurityUtils.async_is_safe_path(
base_path=async_log_root,
user_path=AsyncPath(log_file),
):
raise HTTPException(status_code=404, detail="Not Found")
arcname = f"{zip_stem}/{log_file.name}"
archive.write(log_file, arcname)
zip_buffer.seek(0)
headers = {
"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
}
return StreamingResponse(
iter([zip_buffer.getvalue()]),
media_type="application/zip",
headers=headers,
)
def _validate_nettest_url(url: str) -> Optional[str]:
"""
对实际请求地址做基础安全校验。
@@ -705,7 +803,7 @@ async def get_logging(
request: Request,
length: Optional[int] = 50,
logfile: Optional[str] = "moviepilot.log",
_: schemas.TokenPayload = Depends(verify_resource_token),
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
):
"""
实时获取系统日志
@@ -814,6 +912,17 @@ async def get_logging(
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/logging/download/{name}", summary="下载日志")
async def download_logging(
name: str,
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
):
"""
按日志标识下载主程序或插件滚动日志,返回 zip 文件。
"""
return await _build_log_zip_response(name)
@router.get(
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
)

View File

@@ -1080,7 +1080,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
return dashboard_meta
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> Optional[schemas.PluginDashboard]:
"""
获取插件仪表盘
"""
@@ -1113,6 +1113,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
if dashboard is None:
return None
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
logger.error(f"插件 {pid} 返回的仪表盘数据格式错误")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"插件 {pid} 返回的仪表盘数据格式错误")
cols, attrs, elements = dashboard
return schemas.PluginDashboard(
id=pid,

View File

@@ -1,14 +1,16 @@
---
name: create-moviepilot-plugin
version: 1
version: 2
description: >-
Use this skill when the user asks to create, modify, debug, validate, or
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
_PluginBase implementations, package.v2.json/package.json market metadata,
plugins.v2/plugins source layout, PLUGIN_LOCAL_REPO_PATHS local plugin
sources, plugin APIs, forms, pages, dashboards, commands, services, workflow
actions, agent tools, and local install/reload flows. Also use for Chinese
requests mentioning 编写插件、本地插件源、插件开发、V2插件、插件市场、本地安装插件、插件热加载.
sources, plugin APIs, Vuetify JSON forms/pages/dashboards, Vue module
federation remote components, get_render_mode, get_sidebar_nav, plugin
sidebar pages, commands, services, workflow actions, agent tools, and local
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
---
@@ -22,21 +24,45 @@ a local plugin source and installed into the running MoviePilot instance.
- Host plugin contract: `app/plugins/__init__.py`, especially `_PluginBase`.
- Host plugin discovery, local source sync, install, reload: `app/core/plugin.py`
and `app/helper/plugin.py`.
- Host plugin endpoints, API auth, static files, remotes, and sidebar nav:
`app/api/endpoints/plugin.py`.
- Local development note: `docs/development-setup.md`.
- Plugin repository conventions: `MoviePilot-Plugins` uses `plugins.v2/` with
`package.v2.json` for V2 plugins; legacy or cross-generation entries may use
`plugins/` with `package.json`.
- When working in or from `MoviePilot-Plugins`, read its `README.md`,
`docs/Repository_Guide.md`, and `docs/V2_Plugin_Development.md`. For
scenario-specific extensions, read the matching `docs/faq/*.md`.
- When the plugin uses Vue federation, also read
`MoviePilot-Frontend/docs/module-federation-guide.md`,
`MoviePilot-Frontend/docs/federation-troubleshooting.md`,
`MoviePilot-Frontend/src/utils/federationLoader.ts`, and
`MoviePilot-Frontend/src/pages/plugin-app.vue`.
- Repository boundaries: `MoviePilot` owns runtime loading, API registration,
events, services, data, and permissions; `MoviePilot-Frontend` owns plugin UI
rendering, federation loading, and sidebar pages; `MoviePilot-Plugins` owns
plugin source, icons, package indexes, and release metadata.
## Pre-Flight
1. Understand the user request: plugin purpose, trigger mode, configuration,
output UI, whether it needs a scheduler, API, command, workflow action, or
agent tool.
2. Inspect existing plugins before creating a new one:
2. Run the UI Mode Selection Gate before writing any UI code.
- If the user already explicitly chose JSON config/Vuetify JSON or Vue
federation, follow that choice.
- If the plugin has any UI surface and the user has not chosen a mode, ask
them to choose between the two modes below and wait for the answer before
implementing UI files or schemas.
- Do not silently default to either mode just because one seems easier.
3. Inspect existing plugins before creating a new one:
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
- Market/local source candidates: use `query_market_plugins` when the
running instance is available.
3. Determine the target source path:
- For Vue federation examples, prefer current compliant plugins such as
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
`MoviePilot-Frontend/examples/plugin-component/`.
4. Determine the target source path:
- Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
- If exactly one local plugin repository is configured, prefer that path.
- If several are configured, choose the one the user named; otherwise ask
@@ -47,11 +73,41 @@ a local plugin source and installed into the running MoviePilot instance.
plugin source loader. Create that source directory and write the plugin
under it; do not write new plugin source directly into `app/plugins/`
unless the user explicitly asks for a runtime-only experiment.
4. Choose the plugin ID:
5. Choose the plugin ID:
- Class name is the plugin ID, for example `MyNotifier`.
- Directory name is the class name lowercased, for example `mynotifier`.
- Avoid collisions with installed or market plugins unless the user is
explicitly modifying that plugin.
- Do not hardcode the original plugin ID for data/config namespaces when the
plugin may support clones; use `self.__class__.__name__`.
## UI Mode Selection Gate
MoviePilot plugin UI has exactly two implementation modes. Make the user choose
one whenever the request includes configuration, detail pages, dashboards,
sidebar pages, or any other plugin UI and the mode is not already explicit.
Ask a concise question like:
```text
这个插件 UI 用哪种方式实现?
1. JSON 配置:后端返回 Vuetify JSON适合普通配置表单、简单详情页和轻量仪表板。
2. 联邦 UI独立 Vue 远程组件,适合复杂交互、自定义布局、侧栏全页或多页面。
```
Selection rules:
- **JSON config / Vuetify JSON**: implement `get_form()`, `get_page()`, and
`get_dashboard()` with JSON component schemas. No frontend build or
`dist/assets/remoteEntry.js` is needed.
- **Federation UI / Vue remote component**: implement `get_render_mode()`,
expose Vue components through Vite federation, build frontend assets into the
plugin directory, and use `get_sidebar_nav()` only when a sidebar page is
requested.
- If the plugin truly has no user-facing UI, state that no UI mode is needed
and implement only the backend extension points the request requires.
- Backend-only work may proceed while waiting only if it cannot constrain or
preclude either UI mode.
## Local Source Layout
@@ -67,6 +123,25 @@ Default to V2 layout for new local plugins:
└── ... # helper modules, schemas, static assets
```
For a Vue federation plugin, the runtime requirement is the built remote assets
under the plugin directory:
```text
plugins.v2/<plugin_id_lower>/
├── __init__.py
├── dist/
│ └── assets/
│ ├── remoteEntry.js
│ └── ... # JS/CSS/assets referenced by remoteEntry
├── package.json # optional frontend build project metadata
├── vite.config.js # optional frontend build config
└── src/ # optional source, not required at runtime
```
Do not rely on frontend source files at runtime. If the source is kept in the
plugin repository for maintainability, still build and ship the `dist/assets`
files required by `remoteEntry.js`.
Only use the legacy layout when the user explicitly needs it:
```text
@@ -107,21 +182,29 @@ Rules:
- The package object key must match the plugin class name.
- `version` must match `plugin_version`.
- `name`, `description`, `icon`, `author`, and `level` should match the plugin
class attributes when those attributes exist.
- `name`, `description`, `icon`, `author`, `labels`, and `level` should match
the plugin class attributes when those attributes exist (`plugin_name`,
`plugin_desc`, `plugin_icon`, `plugin_author`, `plugin_label`, `auth_level`).
- `history` should record user-readable changes for each published version.
- Use `system_version` when the plugin depends on a host capability introduced
in a specific MoviePilot version.
in a specific MoviePilot version, including new backend APIs, helpers, events,
Vue federation behavior, sidebar nav, dashboard behavior, or agent tools.
- Use `"release": true` only when the plugin is intentionally distributed by a
GitHub Release archive.
- New plugin entries should usually be appended to the package index so they
appear as newer marketplace items.
- Do not add dependencies unless they are actually required. If
`requirements.txt` changes, the user must reinstall the plugin; hot reload is
not enough to install dependencies.
- Plugin dependencies are installed into the shared MoviePilot Python
environment. Do not pin or downgrade packages already provided by MoviePilot
unless the user has explicitly accepted the compatibility risk.
## Implementation Skeleton
Implement all abstract methods from `_PluginBase`. All new public classes,
public methods, and public functions need Chinese docstrings.
Implement all abstract methods from `_PluginBase`. All new functions and
methods need Chinese docstrings; public classes, public methods, and public
functions are a hard review gate.
```python
from typing import Any, Dict, List, Optional, Tuple
@@ -224,7 +307,8 @@ Use only the extension points the requested plugin actually needs:
- Notification: use `post_message()` instead of directly calling message
modules.
- APIs: return route definitions from `get_api()`; default auth is `apikey`
when `auth` is omitted.
when `auth` is omitted. Vue component APIs should normally use
`auth: "bear"` and be called through the `api` prop passed by the frontend.
- Commands: return slash-command definitions from `get_command()` and dispatch
through MoviePilot events.
- Services: return scheduler services from `get_service()` and always clean
@@ -239,6 +323,118 @@ Use only the extension points the requested plugin actually needs:
satisfy the request. Return `("vue", "<compiled-assets-path>")` and include
built frontend assets in the plugin directory.
## Vue Federation UI
Use Vue federation only after the Pre-Flight UI decision says JSON schema is not
enough. A Vue plugin must align backend methods, built files, and federation
exposes.
Backend requirements:
```python
from typing import Any, Dict, List, Tuple
@staticmethod
def get_render_mode() -> Tuple[str, str]:
"""声明插件使用 Vue 联邦组件渲染。"""
return "vue", "dist/assets"
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""Vue 模式下返回默认配置模型。"""
return [], self._current_config()
def get_page(self) -> List[dict]:
"""Vue 模式下详情页由远程 Page 组件渲染。"""
return []
```
When the plugin needs a main-layout sidebar page, also implement:
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""声明插件在主界面左侧导航栏中的全页入口。"""
if not self.get_state():
return []
return [
{
"nav_key": "main",
"title": "我的插件",
"icon": "mdi-puzzle",
"section": "system",
"permission": "manage",
"order": 10,
}
]
```
Sidebar rules:
- Sidebar entries are only aggregated for enabled plugins whose
`get_render_mode()` returns `"vue"`.
- `section` must be one of `start`, `discovery`, `subscribe`, `organize`,
`system`; invalid values fall back to `system`.
- `permission` may be `subscribe`, `discovery`, `search`, `manage`, or `admin`;
invalid values are ignored.
- `nav_key` defaults to `main` and must not contain `/`, `?`, `#`, or spaces.
- Multiple sidebar entries are allowed; each entry needs a stable `nav_key`.
Frontend federation requirements:
```js
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
},
shared: {
vue: { requiredVersion: false, generate: false },
vuetify: { requiredVersion: false, generate: false, singleton: true },
'vuetify/styles': { requiredVersion: false, generate: false, singleton: true },
},
format: 'esm',
})
```
Build requirements:
- Set Vite `build.target` to `esnext` because federation uses top-level await.
- Use `cssCodeSplit: true` and scoped/component-local styles where possible.
- Build with the frontend project's documented command, then keep `remoteEntry.js`
and every JS/CSS/asset file it references under `dist/assets`.
- Do not add frontend runtime dependencies to the plugin Python
`requirements.txt`; keep frontend dependencies in the frontend build project.
Component contracts:
- `Page` renders the plugin detail dialog and may emit `action`, `switch`, and
`close`.
- `Config` renders plugin settings, receives `initialConfig` and `api`, and
emits `save`, `close`, and `switch`.
- `Dashboard` receives `config` and `allowRefresh`.
- `AppPage` renders the main-layout sidebar page and receives `api`, `pluginId`,
and `navKey`.
- For sidebar `nav_key=main`, the frontend loads `./AppPage` then `./Page`.
- For any other `nav_key`, the frontend loads `./AppPage{PascalCase(nav_key)}`,
then `./AppPage`, then `./Page`. Examples: `settings -> AppPageSettings`,
`my_tool -> AppPageMyTool`.
- A single `AppPage` may branch on `navKey`, or separate
`AppPage{PascalCase}` files may be exposed for specific entries.
Vue API calls:
- Define frontend-facing plugin APIs with `auth: "bear"`.
- Call them with the injected API object, for example
`props.api.get(\`plugin/${props.pluginId}/history\`)`.
- Do not pass `settings.API_TOKEN` into Vue components for browser-side calls.
## Local Install And Reload
1. After writing files in a configured local plugin repository, call
@@ -258,6 +454,17 @@ Use only the extension points the requested plugin actually needs:
and package version are consistent.
- Confirm every public class, public method, and public function has a Chinese
docstring.
- Confirm every newly written function or method has a Chinese docstring, even
when it is private helper code.
- For Vue federation plugins, confirm `get_render_mode()` returns
`("vue", "dist/assets")` or the actual built asset path, and that
`dist/assets/remoteEntry.js` exists.
- For sidebar plugins, confirm the plugin is enabled, `get_state()` returns
`True`, `get_sidebar_nav()` returns valid items, and matching `AppPage`
exposes exist for all non-main `nav_key` values or a generic `AppPage` handles
them.
- Confirm frontend-facing API routes use `auth: "bear"` and browser code calls
them through the provided `api` prop.
- Keep external HTTP calls behind MoviePilot utilities and avoid real network
calls in tests.
- If the plugin has non-trivial logic, add or update pytest-native tests. Plugin
@@ -266,6 +473,27 @@ Use only the extension points the requested plugin actually needs:
- Run the narrowest allowed validation for the touched area. In this repository,
follow `docs/rules/03-commands.md`; for plugin-only repositories, follow their
own documented validation commands.
- For plugin repository Python changes, use the host Python environment when
possible and run at least syntax compilation for touched plugin files.
- For Vue federation changes, run the frontend project's documented typecheck
and build commands when available, then verify the built assets were copied to
the plugin directory.
## Vue Federation Troubleshooting
- `GET /api/v1/plugin/remotes?token=moviepilot` should include the plugin with a
URL ending in `/plugin/file/<plugin_id_lower>/<dist_path>/remoteEntry.js`.
- `GET /api/v1/plugin/sidebar_nav` should include sidebar entries for enabled
Vue plugins with valid `nav_key`, `section`, and `permission`.
- If the console says `Module name 'vue' does not resolve to a valid URL`, check
the federation `shared` config and use `requiredVersion: false`.
- If the console says top-level await is unavailable, set `build.target` to
`esnext`.
- If dynamic import fails, check the remote file request status, the computed
`remoteEntry.js` path, and whether the installed runtime plugin directory
actually contains the built assets.
- If a sidebar page is blank, check the expose name resolution for the current
`nav_key` and fallbacks (`AppPage{PascalCase}` -> `AppPage` -> `Page`).
## Final Report
@@ -273,5 +501,7 @@ Report:
- Plugin ID, source path, and runtime path if installed.
- Package file changed (`package.v2.json` or `package.json`).
- UI mode used (`vuetify` JSON or `vue` federation), and for Vue plugins the
exposed components and built asset path.
- Whether the plugin was installed or reloaded.
- Validation commands run, or why validation was not run.

View File

@@ -0,0 +1,26 @@
import asyncio
from unittest.mock import AsyncMock, patch
from app.api.endpoints import llm as llm_endpoint
def test_llm_test_maps_internal_model_dump_error_to_base_url_hint():
"""LLM 测试遇到 SDK 内部响应解析错误时应提示检查基础地址。"""
with patch.object(llm_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
llm_endpoint.settings, "LLM_PROVIDER", "openai"
), patch.object(llm_endpoint.settings, "LLM_MODEL", "gpt-4o-mini"), patch.object(
llm_endpoint.settings, "LLM_API_KEY", "sk-test"
), patch.object(
llm_endpoint.settings, "LLM_BASE_URL", "https://example.com/not-api"
), patch.object(
llm_endpoint.LLMHelper,
"test_current_settings",
AsyncMock(side_effect=RuntimeError("'str' object has no attribute 'model_dump'")),
create=True,
):
resp = asyncio.run(llm_endpoint.llm_test(_="token"))
assert not resp.success
assert "基础地址" in resp.message
assert "API Base URL" in resp.message
assert "model_dump" not in resp.message

View File

@@ -0,0 +1,65 @@
from types import SimpleNamespace
from typing import Any, Iterator
import pytest
from fastapi import HTTPException
from app.core.plugin import PluginManager
from app.utils.singleton import Singleton
@pytest.fixture
def plugin_manager() -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
yield manager
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def _plugin_with_dashboard(dashboard: Any) -> SimpleNamespace:
"""构造仅包含仪表板接口的插件实例。"""
return SimpleNamespace(
plugin_name="演示插件",
get_render_mode=lambda: ("vue", "dist/assets"),
get_dashboard=lambda key=None, user_agent=None: dashboard,
)
def test_plugin_dashboard_keeps_vue_elements_none(plugin_manager: PluginManager) -> None:
"""Vue 仪表板的 elements=None 应原样返回给前端渲染远程组件。"""
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
(
{"cols": 12},
{"title": "演示插件", "border": True},
None,
)
)
dashboard = plugin_manager.get_plugin_dashboard("DemoPlugin", "usage")
assert dashboard.id == "DemoPlugin"
assert dashboard.render_mode == "vue"
assert dashboard.cols == {"cols": 12}
assert dashboard.attrs == {"title": "演示插件", "border": True}
assert dashboard.elements is None
def test_plugin_dashboard_returns_none_when_plugin_has_no_dashboard(plugin_manager: PluginManager) -> None:
"""插件声明当前无仪表板时应返回 None而不是触发解包异常。"""
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(None)
assert plugin_manager.get_plugin_dashboard("DemoPlugin", "missing") is None
def test_plugin_dashboard_rejects_invalid_dashboard_shape(plugin_manager: PluginManager) -> None:
"""非空但不符合三元组契约的仪表板数据应返回服务端错误。"""
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
{"cols": {}, "attrs": {}, "elements": []}
)
with pytest.raises(HTTPException) as exc_info:
plugin_manager.get_plugin_dashboard("DemoPlugin", "broken")
assert exc_info.value.status_code == 500
assert "仪表盘数据格式错误" in exc_info.value.detail

View File

@@ -0,0 +1,116 @@
"""系统日志查看与下载接口的权限和打包行为测试。"""
import asyncio
import io
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.responses import Response
from app.api.endpoints import system as system_endpoint
from app.core.config import settings
def test_logging_routes_use_superuser_dependency():
"""日志查看和下载路由都必须绑定管理员依赖,避免普通登录用户读取敏感日志。"""
routes = {route.path: route for route in system_endpoint.router.routes}
logging_dependencies = {dependency.call for dependency in routes["/logging"].dependant.dependencies}
download_dependencies = {dependency.call for dependency in routes["/logging/download/{name}"].dependant.dependencies}
assert system_endpoint._verify_log_resource_superuser in logging_dependencies
assert system_endpoint._verify_log_resource_superuser in download_dependencies
def test_log_resource_dependency_rejects_normal_user():
"""日志资源依赖必须拒绝非管理员 resource token。"""
with pytest.raises(HTTPException) as exc_info:
system_endpoint._verify_log_resource_superuser(
SimpleNamespace(super_user=False),
)
assert exc_info.value.status_code == 403
@pytest.fixture(name="isolated_log_path")
def fixture_isolated_log_path(monkeypatch, tmp_path: Path) -> Path:
"""将日志目录隔离到临时目录,避免测试读取或打包真实运行日志。"""
config_path = tmp_path / "config"
log_path = config_path / "logs"
log_path.mkdir(parents=True)
monkeypatch.setattr(settings, "CONFIG_DIR", str(config_path))
return log_path
def test_logging_requires_superuser_dependency(monkeypatch, isolated_log_path):
"""实时日志查看接口必须通过管理员依赖,普通资源令牌不能直接读取日志。"""
(isolated_log_path / "moviepilot.log").write_text("hello\n", encoding="utf-8")
response = asyncio.run(
system_endpoint.get_logging(
request=SimpleNamespace(is_disconnected=lambda: False),
length=-1,
logfile="moviepilot.log",
_=SimpleNamespace(id=1, name="admin", is_superuser=True),
)
)
assert isinstance(response, Response)
def test_download_moviepilot_logs_packages_latest_ten_log_files(isolated_log_path):
"""传入 moviepilot 时下载主程序滚动日志,最多打包 10 个文件。"""
for index in range(12):
(isolated_log_path / f"moviepilot.log.{index}").write_text(f"old-{index}", encoding="utf-8")
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
(isolated_log_path / "moviepilot.txt").write_text("ignored", encoding="utf-8")
(isolated_log_path / "plugins").mkdir()
(isolated_log_path / "plugins" / "demo.log").write_text("plugin", encoding="utf-8")
response = asyncio.run(system_endpoint.download_logging(name="moviepilot", _=SimpleNamespace()))
body = asyncio.run(_read_streaming_body(response))
with zipfile.ZipFile(io.BytesIO(body)) as archive:
names = archive.namelist()
moviepilot_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
assert response.media_type == "application/zip"
assert 'filename="moviepilot-logs-' in response.headers["Content-Disposition"]
assert "moviepilot-moviepilot-logs" not in response.headers["Content-Disposition"]
assert len(names) == 10
assert f"{moviepilot_zip_root}/moviepilot.log" in names
assert "moviepilot.log" not in names
assert "plugins/demo.log" not in names
assert "moviepilot.txt" not in names
def test_download_plugin_logs_packages_plugin_files_only(isolated_log_path):
"""传入插件 ID 时只下载该插件滚动日志,最多打包 10 个文件。"""
plugin_dir = isolated_log_path / "plugins"
plugin_dir.mkdir()
for index in range(11):
(plugin_dir / f"demoplugin.log.{index}").write_text(f"plugin-{index}", encoding="utf-8")
(plugin_dir / "demoplugin.log").write_text("current", encoding="utf-8")
(plugin_dir / "other.log").write_text("other", encoding="utf-8")
(isolated_log_path / "moviepilot.log").write_text("main", encoding="utf-8")
response = asyncio.run(system_endpoint.download_logging(name="DemoPlugin", _=SimpleNamespace()))
body = asyncio.run(_read_streaming_body(response))
with zipfile.ZipFile(io.BytesIO(body)) as archive:
names = archive.namelist()
plugin_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
assert len(names) == 10
assert f"{plugin_zip_root}/demoplugin.log" in names
assert "demoplugin.log" not in names
assert "plugins/demoplugin.log" not in names
assert "plugins/other.log" not in names
assert "moviepilot.log" not in names
async def _read_streaming_body(response) -> bytes:
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
return b"".join([chunk async for chunk in response.body_iterator])

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.13.8'
APP_VERSION = 'v2.13.8-1'
FRONTEND_VERSION = 'v2.13.8'