Files
MoviePilot/tests/test_agent_api_gateway.py
Aqr-K e47d7defe7 feat(plugin): 插件本体与分身可各自设置独立日志等级
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行
日志会淹没在其他几十个插件与宿主自身的 DEBUG 输出里,找不出来;不下调又什么都
看不见。插件实例数量只会继续增长,这个矛盾不会自己消失。

因此把等级下放到实例:plugininstance 行加 log_level / log_expires_at 两列,本体与
分身共用同一张表、同一套语义。等级落在实例自己那一行而不是另起一张按实例 ID 索引
的表——它和业务参数一样是用户按实例设置的东西,同一个生命周期,实例被删时应当随行
一起消失。清除覆盖后若本体行只剩一对身份列,整行按 carries_only_identity 回收,与
清空业务参数同一规则。

覆盖带失效时间,因为它是调试设置不是长期配置:开了 DEBUG 忘记关,下次谁也想不起
某个插件为什么一直在刷日志。过期判定在读取时惰性执行并就地清理,不另起后台线程去
扫一张最多几十条的表。expires_at 在写入侧先归一到 UTC 再分发给缓存与落盘两处:客户端
提交的时间可能不带时区,两处各按进程时区折算一次,就会与读取口返回的 UTC 值差出一个
时区,表现为「提交的失效时刻和读回来的对不上」。

运行期用 ContextVar 在宿主自己控制的四类调用点绑定当前实例——构造与 init_plugin、
事件处理器、定时服务回调、插件声明的 HTTP 端点——而不是沿用 logger() 已有的栈帧内省。
内省认的是栈顶的模块与类,插件通过宿主公共方法转发调用时栈顶是宿主而不是发起调用的
插件,会把日志记到错误的来源上;分身更甚:分身共享源码,类的 __qualname__ 保持源类名
不变,内省根本分不出是哪一个分身在打日志。内省结果继续只用于文件路由,不参与等级判定。

等级过滤必须排在全局等级判定之前。官方现状是先按全局等级短路、之后才做内省,先短路
就会把实例调低的等级挡在门外,后面再怎么识别来源都看不到这条日志。

回调在注册时被捕获、稍后才由调度器或 FastAPI 调用,那时已经不在任何绑定作用域内,
故这两处改用 wrap_for_plugin_instance 把绑定包进回调自身;同一实例重复包装直接返回
原对象,避免插件缓存声明时包装链随重载次数无限增长。

进程内覆盖表按实例 ID 常驻,删分身时必须一并清掉:只删库里那一行的话,同一进程内用
相同后缀重建的分身会继承上一个分身的等级,界面显示「跟随全局」而实际仍按旧等级输出。
进程重启后反向从库预热缓存。

迁移链在 F1 之后追加:c4e1a7b9d2f6(3.0.35)-> 487f7e681955(3.0.36 加两列)。

顺带补了 agent 策略里缺失的 media.classification.policy.get 的 template 字段说明:
少了它,api_mcp_schema.json 与技能文档的两个生成脚本都跑不起来。
2026-09-12 19:05:05 -04:00

583 lines
22 KiB
Python
Raw 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.
import asyncio
import json
import re
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
# pylint: disable=no-name-in-module # 策略包根通过 __getattr__ 惰性导出,Pylint 无法静态解析。
from app.agent.policy import (
DEFAULT_TOOL_POLICY_REGISTRY,
ActionEffect,
ConfirmationMode,
PrincipalRole,
RecoveryMode,
)
from app.agent.policy.api import (
API_EXTENDED_OPERATION_SPECS,
API_FIRST_BATCH_OPERATION_SPECS,
API_MUSIC_OPERATION_SPECS,
API_OPERATION_ROUTES,
API_OPERATION_SPECS,
API_PARITY_OPERATION_SPECS,
API_SYSTEM_OPERATION_SPECS,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.agent_task import AgentTaskTool
from app.agent.tools.impl.api import MoviePilotApiTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.manager import MoviePilotToolsManager
def test_api_operation_registry_matches_migration_batches() -> None:
"""API 操作注册表必须覆盖各迁移批次且每项具有固定路由。"""
assert len(API_FIRST_BATCH_OPERATION_SPECS) == 53
assert len(API_PARITY_OPERATION_SPECS) == 15
assert len(API_MUSIC_OPERATION_SPECS) == 10
assert len(API_SYSTEM_OPERATION_SPECS) == 7
assert len(API_EXTENDED_OPERATION_SPECS) == 139
assert len(API_OPERATION_SPECS) == 224
assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES)
assert {
"download.list",
"download.update",
"download.delete",
"downloaders.list",
}.isdisjoint(API_OPERATION_ROUTES)
assert {
"plugin.source.options",
"plugin.source.install",
"plugin.source.change",
"download.tasks.active",
"download.clients",
"download.paths",
"download.history.list",
"library.latest",
"system.versions",
"system.update.status",
"system.update.check",
"system.update.download",
"system.restart",
"system.update.install",
"system.upgrade.dev",
"dashboard.system",
"media.sources",
"search.title",
"site.add",
"subscription.get",
"storage.rename",
"transfer.manual_reviews",
"workflow.create",
"torrent.cache.get",
"database.backups.list",
"system.module.list",
"plugin.clone",
}.issubset(API_OPERATION_ROUTES)
def test_classification_operations_expose_versioned_policy_contract() -> None:
"""媒体自动分类必须只暴露新版查询、校验、预览和版本化写入合同。"""
expected_routes = {
"media.classification.fields": ("GET", "/api/v1/media/classification/fields"),
"media.classification.policy.get": ("GET", "/api/v1/media/classification/policy"),
"media.classification.policy.validate": ("POST", "/api/v1/media/classification/validate"),
"media.classification.policy.preview": ("POST", "/api/v1/media/classification/preview"),
"media.classification.policy.impact": ("POST", "/api/v1/media/classification/impact"),
"media.classification.policy.history": ("GET", "/api/v1/media/classification/history"),
"media.classification.policy.update": ("PUT", "/api/v1/media/classification/policy"),
"media.classification.policy.rollback": (
"POST",
"/api/v1/media/classification/rollback/{revision}",
),
}
assert {
operation_id: (route.method, route.path)
for operation_id, route in API_OPERATION_ROUTES.items()
if operation_id.startswith("media.classification.")
} == expected_routes
assert {"media.categories", "media.category.config.get"}.isdisjoint(API_OPERATION_ROUTES)
specs = {spec.operation_id: spec for spec in API_OPERATION_SPECS}
assert specs["media.classification.policy.update"].effect is ActionEffect.REVERSIBLE_WRITE
assert specs["media.classification.policy.rollback"].effect is ActionEffect.REVERSIBLE_WRITE
assert specs["media.classification.policy.update"].required_role is PrincipalRole.SYSTEM_ADMIN
assert specs["media.classification.policy.rollback"].required_role is PrincipalRole.SYSTEM_ADMIN
assert specs["media.classification.policy.update"].confirmation is ConfirmationMode.REQUIRED
assert specs["media.classification.policy.rollback"].confirmation is ConfirmationMode.REQUIRED
assert specs["media.classification.policy.update"].recovery is RecoveryMode.TRANSACTION
assert specs["media.classification.policy.rollback"].recovery is RecoveryMode.TRANSACTION
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
assert branches["media.classification.policy.update"]["properties"]["body"]["$ref"].endswith(
"/ClassificationPolicyPublishRequest"
)
assert branches["media.classification.policy.preview"]["properties"]["body"]["$ref"].endswith(
"/ClassificationPreviewRequest"
)
assert branches["media.classification.policy.rollback"]["properties"]["path_params"]["required"] == [
"revision"
]
def test_api_tool_message_displays_secret_safe_major_parameters() -> None:
"""啰嗦模式提示应展示 API 主要参数,同时对设置凭据值脱敏。"""
tool = MoviePilotApiTool(session_id="session", user_id="api_user")
list_message = tool.get_tool_message(
operation_id="subscription.list",
query={"page": 1, "count": 20},
)
secret_message = tool.get_tool_message(
operation_id="config.system.update",
body={
"setting_key": "OPENAI_API_KEY",
"value": "sk-secret-value",
"operation": "replace",
},
)
assert list_message == (
'调用 MoviePilot API:subscription.list,主要参数:'
'{"query": {"page": 1, "count": 20}}'
)
assert "OPENAI_API_KEY" in secret_message
assert '"value": "***"' in secret_message
assert "sk-secret-value" not in secret_message
def test_music_operations_expose_bidirectional_artist_album_navigation() -> None:
"""音乐 Skill 必须完整暴露作品到作者、作者到作品及关联浏览合同。"""
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
assert {
"music.recognize",
"music.explore",
"music.album.get",
"music.album.related",
"music.artist.get",
"music.artist.albums",
"music.artist.related",
"music.cache.get",
"music.cache.delete",
"music.cache.clear",
}.issubset(branches)
album_path = branches["music.album.get"]["properties"]["path_params"]
assert album_path["required"] == ["album_id"]
artist_albums = branches["music.artist.albums"]
assert artist_albums["properties"]["path_params"]["required"] == ["artist_id"]
album_type = artist_albums["properties"]["query"]["properties"]["album_type"]
assert "single" in str(album_type)
assert branches["music.cache.delete"]["properties"]["path_params"]["required"] == [
"cache_key"
]
def test_mcp_tools_list_preserves_all_moviepilot_api_operation_branches() -> None:
"""外部 MCP tools/list 必须返回全部 operation 的精确 oneOf,而不是通用字典。"""
manager = MoviePilotToolsManager(session_id="session", user_id="api_user")
manager.tools = [MoviePilotApiTool(session_id="session", user_id="api_user")]
definition = manager.list_tools()[0]
operation_ids = {
branch["properties"]["operation_id"]["const"]
for branch in definition.input_schema["oneOf"]
}
assert definition.name == "moviepilot_api"
assert operation_ids == set(API_OPERATION_ROUTES)
def test_mcp_collection_contract_distinguishes_exact_and_unavailable_totals() -> None:
"""MCP 必须说明缺省全量、精确总数和外部无总数三种集合语义。"""
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
subscription = branches["subscription.list"]
subscription_query = subscription["properties"]["query"]["properties"]
assert "default" not in subscription_query["page"]
assert "default" not in subscription_query["count"]
assert subscription["x-moviepilot-collection"] == {
"body_shape": "list",
"result_count_field": "collection.result_count",
"total_count_field": "collection.total_count",
"default_pagination": "unpaginated",
}
assert "page=1 and count=1" in subscription["description"]
assert "do not query the database" in subscription["description"]
storage = branches["storage.list"]
assert {"page", "count"}.issubset(storage["properties"]["query"]["properties"])
assert storage["x-moviepilot-collection"]["total_count_field"] == (
"collection.total_count"
)
for operation_id in ("subscription.history", "download.history.list"):
local_page = branches[operation_id]["x-moviepilot-collection"]
assert local_page["total_count_field"] == "collection.total_count"
assert local_page["default_pagination"] == "endpoint-defined"
assert "defaults remain in effect" in branches[operation_id]["description"]
for operation_id in ("plugin.installed", "plugin.market"):
local_page = branches[operation_id]["x-moviepilot-collection"]
assert local_page["total_count_field"] == "collection.total_count"
assert local_page["default_pagination"] == "unpaginated"
assert "omit both page and count" in branches[operation_id]["description"]
assert "default" not in branches[operation_id]["properties"]["query"]["properties"]["max_results"]
media_search = branches["media.search"]["x-moviepilot-collection"]
assert media_search["result_count_field"] == "collection.result_count"
assert media_search["total_count_field"] is None
assert "does not expose a total" in branches["media.search"]["description"]
transfer = branches["transfer.history"]["x-moviepilot-collection"]
assert transfer["body_shape"] == "page_object"
assert transfer["items_field"] == "data.list"
assert transfer["total_count_field"] == "data.total"
def test_filter_read_parameters_are_query_fields_not_get_request_bodies() -> None:
"""规则读取筛选列表必须作为 query 参数公开,避免 Agent 构造无语义 GET body。"""
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
assert "body" not in branches["filter.builtin"]["properties"]
assert "rule_ids" in branches["filter.builtin"]["properties"]["query"]["properties"]
assert "body" not in branches["filter.custom"]["properties"]
assert "rule_ids" in branches["filter.custom"]["properties"]["query"]["properties"]
assert "body" not in branches["filter.groups"]["properties"]
assert "group_names" in branches["filter.groups"]["properties"]["query"]["properties"]
def test_plugin_operations_expose_discovery_before_precise_writes() -> None:
"""插件配置和来源操作必须给 Agent 可先发现再精确写入的完整合同。"""
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
installed_query = branches["plugin.installed"]["properties"]["query"]
assert installed_query["properties"]["state"]["const"] == "installed"
assert "query" in installed_query["properties"]
max_results = installed_query["properties"]["max_results"]
integer_variant = next(item for item in max_results["anyOf"] if item.get("type") == "integer")
assert integer_variant["maximum"] == 200
config_get_path = API_OPERATION_ROUTES["plugin.config.get"].path
assert config_get_path == "/api/v1/plugin/form/{plugin_id}"
config_body = branches["plugin.config.update"]["properties"]["body"]
assert config_body["minProperties"] == 1
assert "First call plugin.config.get" in config_body["description"]
assert branches["plugin.source.install"]["properties"]["body"]["$ref"].endswith(
"/PluginSourceInstallRequest"
)
assert branches["plugin.source.change"]["properties"]["body"]["$ref"].endswith(
"/PluginSourceChangeRequest"
)
def test_dev_upgrade_mcp_schema_requires_exact_scalar_body() -> None:
"""Dev 更新必须暴露精确字符串 body,不能回退为任意 JSON。"""
schema = MoviePilotApiTool(
session_id="session",
user_id="api_user",
).get_mcp_input_schema()
branch = next(
item
for item in schema["oneOf"]
if item["properties"]["operation_id"].get("const") == "system.upgrade.dev"
)
assert branch["properties"]["body"]["const"] == "dev"
assert branch["properties"]["body"]["type"] == "string"
assert "body" in branch["required"]
def test_gateway_forwards_exact_scalar_body() -> None:
"""网关必须原样传递少数固定 operation 声明的 JSON 标量请求体。"""
executor = AsyncMock()
executor.execute.return_value = json.dumps({"success": True})
gateway = MoviePilotApiTool(
session_id="session",
user_id="api_user",
executor=executor,
)
gateway.set_agent_context({"is_admin": True})
result = asyncio.run(
gateway.run(
operation_id="system.upgrade.dev",
body="dev",
)
)
assert json.loads(result)["success"] is True
executor.execute.assert_awaited_once_with(
"system.upgrade.dev",
path_params=None,
query=None,
body="dev",
)
def test_system_settings_mcp_schema_explains_both_setting_sources() -> None:
"""外部 MCP Client 应直接看到系统设置发现与精确更新参数语义。"""
schema = MoviePilotApiTool(
session_id="session",
user_id="api_user",
).get_mcp_input_schema()
branches = {
branch["properties"]["operation_id"]["const"]: branch
for branch in schema["oneOf"]
}
query = branches["config.system.get"]["properties"]["query"]["properties"]
update_ref = branches["config.system.update"]["properties"]["body"]["$ref"]
update = schema["$defs"][update_ref.rsplit("/", 1)[-1]]["properties"]
assert "Settings field names" in query["setting_key"]["description"]
assert "systemconfig" in query["group"]["description"]
assert "confirmation-protected" in query["show_secrets"]["description"]
assert "config.system.get" in update["setting_key"]["description"]
assert "upsert_list_item" in update["operation"]["description"]
assert "NotificationSwitchs" in update["match_field"]["description"]
def test_api_mcp_schema_gives_every_field_concrete_english_guidance() -> None:
"""MCP 合同字段不得回退为抽象占位说明或中英文混排。"""
schema = MoviePilotApiTool(
session_id="session",
user_id="api_user",
).get_mcp_input_schema()
descriptions = []
def collect(node) -> None:
"""递归收集对象字段说明并断言没有遗漏。"""
if isinstance(node, dict):
properties = node.get("properties")
if isinstance(properties, dict):
for field_name, field_schema in properties.items():
assert isinstance(field_schema, dict), field_name
description = field_schema.get("description")
assert isinstance(description, str) and description.strip(), field_name
descriptions.append(description)
for value in node.values():
collect(value)
elif isinstance(node, list):
for value in node:
collect(value)
collect(schema)
rendered = "\n".join(descriptions)
assert "按接口模型语义传值" not in rendered
assert "declared by the selected operation" not in rendered
assert "use the matching oneOf branch for its exact type" not in rendered
assert not re.search(r"[\u3400-\u9fff]", rendered)
def test_policy_classifies_api_operation_by_operation_id() -> None:
"""网关策略必须按 operation ID 分类,而不能把整个网关视为普通读取。"""
delete_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api",
arguments={"operation_id": "subscription.delete"},
requires_admin=False,
)
unknown_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api",
arguments={"operation_id": "unknown.operation"},
requires_admin=False,
)
assert delete_policy.effect is ActionEffect.DESTRUCTIVE_WRITE
assert delete_policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert delete_policy.confirmation is ConfirmationMode.REQUIRED
assert unknown_policy.machine_allowed is False
def test_gateway_forwards_structured_arguments_to_api_executor() -> None:
"""网关应只把结构化白名单调用转发给固定 API 执行器。"""
executor = AsyncMock()
executor.execute.return_value = json.dumps({"success": True})
gateway = MoviePilotApiTool(
session_id="session",
user_id="1",
executor=executor,
)
result = asyncio.run(
gateway.run(
operation_id="media.search",
query={"page": 1, "title": "示例"},
)
)
assert json.loads(result)["success"] is True
executor.execute.assert_awaited_once_with(
"media.search",
path_params=None,
query={"page": 1, "title": "示例"},
body=None,
)
def test_gateway_rejects_unknown_operation() -> None:
"""未知 operation ID 必须在调用固定 API 执行器前稳定失败。"""
gateway = MoviePilotApiTool(session_id="session", user_id="user")
result = asyncio.run(gateway.run(operation_id="arbitrary.http", body={"path": "/admin"}))
assert '"success": false' in result
assert "unknown_operation" in result
def test_gateway_returns_operation_contract_when_input_is_invalid() -> None:
"""单个 operation 的参数错误必须返回可直接纠正调用的精确合同。"""
executor = AsyncMock()
gateway = MoviePilotApiTool(
session_id="session",
user_id="1",
executor=executor,
)
result = asyncio.run(
gateway.run(
operation_id="site.list",
query={"status": "enabled"},
)
)
payload = json.loads(result)
assert payload["error"] == "invalid_input"
assert payload["operation_id"] == "site.list"
assert payload["input_contract"]["allowed_arguments"] == ["query"]
status = payload["input_contract"]["query"]["fields"]["status"]
assert status["enum"] == ["active", "inactive", "all"]
assert "enabled" not in json.dumps(payload, ensure_ascii=False)
executor.execute.assert_not_awaited()
def test_gateway_rejects_admin_operation_for_non_admin_before_http() -> None:
"""管理员 operation 必须在网关层拒绝普通用户,不能只依赖最终端点。"""
executor = AsyncMock()
gateway = MoviePilotApiTool(
session_id="session",
user_id="1",
executor=executor,
)
gateway.set_agent_context({"is_admin": False})
result = asyncio.run(
gateway.run(
operation_id="config.system.get",
query={"group": "settings"},
)
)
assert json.loads(result)["error"] == "permission_denied"
executor.execute.assert_not_awaited()
def test_gateway_resolves_http_manager_to_persisted_superuser() -> None:
"""MCP/HTTP 管理入口应绑定真实管理员,而不是伪造 api_user 身份。"""
gateway = MoviePilotApiTool(session_id="session", user_id="api_user")
gateway.set_message_attr(channel=None, source="api", username="API Client")
gateway.set_agent_context({"is_admin": True})
with patch(
"app.application.security.auth.build_superuser_token_payload",
return_value=SimpleNamespace(
sub=7,
username="admin",
super_user=True,
),
):
identity = asyncio.run(gateway._resolve_api_identity())
assert identity == ("7", "admin", True)
def test_gateway_maps_verified_channel_admin_only_for_admin_operation() -> None:
"""通知渠道管理员执行管理员 operation 时应延续旧工具的管理员语义。"""
gateway = MoviePilotApiTool(session_id="session", user_id="telegram-user")
gateway.set_message_attr(
channel="telegram",
source="main-bot",
username="channel-user",
)
gateway.set_agent_context({"is_admin": True})
with patch(
"app.application.security.auth.build_superuser_token_payload",
return_value=SimpleNamespace(
sub=7,
username="admin",
super_user=True,
),
):
identity = asyncio.run(
gateway._resolve_api_identity(require_system_admin=True)
)
assert identity == ("7", "admin", True)
def test_factory_uses_api_catalog_by_default(monkeypatch) -> None:
"""统一工具工厂默认只暴露原生能力和 API 网关。"""
monkeypatch.setattr(
MoviePilotToolFactory,
"BUILTIN_TOOL_CLASSES",
(),
)
monkeypatch.setattr(
"app.agent.tools.factory._get_plugin_agent_tools",
lambda: [],
)
tools = MoviePilotToolFactory.create_tools(
session_id="session",
user_id="user",
)
assert [tool.name for tool in tools] == [
"send_local_file",
"moviepilot_api",
]
def test_factory_keeps_native_tools_with_api_catalog(monkeypatch) -> None:
"""统一目录保留原生工具,并追加单一 API 网关。"""
monkeypatch.setattr(
MoviePilotToolFactory,
"BUILTIN_TOOL_CLASSES",
(AgentTaskTool, ExecuteCommandTool),
)
monkeypatch.setattr(
"app.agent.tools.factory._get_plugin_agent_tools",
lambda: [],
)
tools = MoviePilotToolFactory.create_tools(
session_id="session",
user_id="user",
)
assert [tool.name for tool in tools] == [
"agent_task",
"execute_command",
"send_local_file",
"moviepilot_api",
]