mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行 日志会淹没在其他几十个插件与宿主自身的 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 与技能文档的两个生成脚本都跑不起来。
583 lines
22 KiB
Python
583 lines
22 KiB
Python
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",
|
||
]
|