Files
MoviePilot/tests/test_agent_api_gateway.py

961 lines
38 KiB
Python
Raw Permalink 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.
"""
Agent API 网关的输入、权限、执行结果与 MCP 发现契约测试。
"""
import asyncio
import json
import re
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from langchain_core.utils.function_calling import convert_to_openai_tool
from app.agent.api.arguments import api_input_contract
# 策略包根通过 __getattr__ 惰性导出,Pylint 无法静态解析。
# pylint: disable=no-name-in-module
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.policy.mcp import _apply_field_descriptions
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.agent_task import AgentTaskTool
from app.agent.tools.impl.api import MoviePilotApiInput, MoviePilotApiTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.db.adapters.user import TransactionalUserRepository
from app.db.models.user import User
from app.db.session import SessionFactory, async_session_scope
from app.schemas.types import NotificationChannel
def test_api_operation_registry_matches_migration_batches() -> None:
"""
API 操作注册表必须覆盖各迁移批次且每项具有固定路由。
"""
assert len(API_FIRST_BATCH_OPERATION_SPECS) == 53
assert len(API_PARITY_OPERATION_SPECS) == 17
assert len(API_MUSIC_OPERATION_SPECS) == 10
assert len(API_SYSTEM_OPERATION_SPECS) == 7
assert len(API_EXTENDED_OPERATION_SPECS) == 145
assert len(API_OPERATION_SPECS) == 232
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_local_agent_tool_restores_json_encoded_object_bodies() -> None:
"""
本地 Agent 工具 schema 不得把 JSON 对象字符串化作为合法请求体。
部分模型在 body 声明为 anyOf 联合类型时会把整个请求体编码成 JSON 字符串;
规范化阶段将其还原为原生对象,非 JSON 的非法字符串仍按合同报错。
"""
tool = MoviePilotApiTool(session_id="session", user_id="api_user")
schema = tool.tool_call_schema
assert not isinstance(schema, dict)
body_schema = schema.model_json_schema()["properties"]["body"]
body_branches = body_schema["anyOf"]
body_types = {branch.get("type") for branch in body_branches}
assert {"object", "array", "string", "null"}.issubset(body_types)
assert any(branch.get("const") == "dev" for branch in body_branches)
assert all(
branch.get("type") != "string" or branch.get("const") == "dev"
for branch in body_branches
)
body = {
"torrent_in": {
"title": "示例种子",
"enclosure": "https://example.com/test.torrent",
},
}
canonical = tool.canonical_arguments({"operation_id": "download.add", "body": body})
assert canonical["body"]["torrent_in"]["enclosure"] == body["torrent_in"]["enclosure"]
# 模型把 body 编码为 JSON 字符串时,规范化阶段还原为原生对象。
restored = tool.canonical_arguments(
{
"operation_id": "download.add",
"body": json.dumps(body, ensure_ascii=False),
}
)
assert isinstance(restored["body"], dict)
assert restored["body"]["torrent_in"]["enclosure"] == body["torrent_in"]["enclosure"]
# LangChain 入模同样必须还原,否则 canonical_arguments 通过后仍会在 Pydantic 阶段失败。
restored_model = MoviePilotApiInput.model_validate(
{
"operation_id": "download.add",
"body": json.dumps(body, ensure_ascii=False),
}
)
assert isinstance(restored_model.body, dict)
assert restored_model.body["torrent_in"]["enclosure"] == body["torrent_in"]["enclosure"]
# 固定的 system.upgrade.dev 字面量继续作为唯一合法字符串请求体。
dev_model = MoviePilotApiInput.model_validate(
{"operation_id": "system.upgrade.dev", "body": "dev"}
)
assert dev_model.body == "dev"
# 无法还原为对象/数组的非法字符串仍按合同报错。
with pytest.raises(ValueError):
tool.canonical_arguments(
{
"operation_id": "download.add",
"body": "not-a-json-object",
}
)
def test_operation_body_shapes_fit_local_agent_input_schema() -> None:
"""
所有 MCP operation 请求体必须可由内置 Agent 输入类型表达。
"""
tool = MoviePilotApiTool(session_id="session", user_id="api_user")
api_schema = tool.get_mcp_input_schema()
definitions = api_schema.get("$defs", {})
string_bodies = set()
for operation in api_schema["oneOf"]:
properties = operation.get("properties", {})
body_schema = properties.get("body")
if body_schema is None:
continue
while "$ref" in body_schema:
body_schema = definitions[body_schema["$ref"].rsplit("/", 1)[-1]]
variants = body_schema.get("anyOf", body_schema.get("oneOf", [body_schema]))
operation_id = properties["operation_id"]["const"]
for variant in variants:
while "$ref" in variant:
variant = definitions[variant["$ref"].rsplit("/", 1)[-1]]
if variant.get("type") == "string":
string_bodies.add((operation_id, variant.get("const")))
else:
assert variant.get("type") in {"object", "array", "null"}
assert string_bodies == {("system.upgrade.dev", "dev")}
def test_local_api_schema_forbids_flattened_arguments_and_examples_match_contracts() -> None:
"""
模型实际接收的 schema 禁止额外顶层字段,内联示例均通过真实 operation 合同。
"""
tool = MoviePilotApiTool(session_id="session", user_id="1")
definition = convert_to_openai_tool(tool)["function"]
schema = definition["parameters"]
assert definition["description"] == tool.description
assert schema["additionalProperties"] is False
assert set(schema["properties"]) == {"operation_id", "path_params", "query", "body"}
with pytest.raises(ValueError):
MoviePilotApiInput.model_validate({"operation_id": "site.rss", "page": 1})
examples = tool.description.split("示例中的标识须替换为此前结果中的实际标识:")[1]
decoder = json.JSONDecoder()
operations = []
for _ in range(3):
example, end = decoder.raw_decode(examples)
operations.append(tool.canonical_arguments(example)["operation_id"])
examples = examples[end:].lstrip("; ")
assert operations == ["media.detail", "subscription.execution.list", "site.rss"]
@pytest.mark.parametrize(("arguments", "expected"), [
({"operation_id": "media.detail", "media_id": "secret-value", "media_source": "tmdb"},
["media_id 应移入 path_params.media_id", "media_source 应移入 query.media_source"]),
({"operation_id": "subscription.execution.list", "limit": 2}, ["limit 应移入 query.limit"]),
({"operation_id": "site.rss", "query": {"site_id": "secret-value"}}, ["query", "site_id"]),
({"operation_id": "media.detail"}, ["path_params.media_id"]),
({"operation_id": "media.detail", "path_params": {"media_id": "27205"}}, ["query.media_source"]),
({"operation_id": "subscription.execution.list", "query": {"limit": "secret-value"}}, ["query.limit"]),
({"operation_id": "site.list", "query": {"status": "secret-value"}}, ["query.status"]),
({"operation_id": "download.add", "torrent_in": {}}, ["torrent_in 应移入 body.torrent_in"]),
({"operation_id": "config.system.update", "body": {"value": "secret-value"}}, ["body.setting_key"]),
({"operation_id": "site.rss", "https://secret-value.invalid/": "secret-value"}, ["字段名已省略"]),
])
@pytest.mark.asyncio
async def test_gateway_raw_tool_inputs_return_actionable_errors_without_execution(arguments, expected) -> None:
"""
覆盖 LangChain 和直接调用入口,错误字段不能静默丢弃、执行或泄露参数值。
"""
executor = AsyncMock()
gateway = MoviePilotApiTool(session_id="session", user_id="1", executor=executor)
message = await gateway.ainvoke({"name": gateway.name, "args": arguments, "id": "invalid", "type": "tool_call"})
assert message.status == "error"
for result in (message.content, await gateway.run(**arguments)):
payload = json.loads(result)
assert payload["error"] == "invalid_input"
assert payload["input_contract"]["operation_id"] == arguments["operation_id"]
assert all(item in payload["message"] for item in expected)
assert "secret-value" not in result
executor.execute.assert_not_awaited()
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 和 count=1" in subscription["description"]
assert "不要仅因条目数据被截断就查询数据库" 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 "默认值继续生效" 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 "同时省略 page 和 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 "未提供总数" 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 "先调用 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 字段名" in query["setting_key"]["description"]
assert "systemconfig" in query["group"]["description"]
assert "确认" in query["show_secrets"]["description"]
assert "config.system.get" in update["setting_key"]["description"]
assert "upsert_list_item" in update["operation"]["description"]
assert "通知场景" in update["match_field"]["description"]
assert "type" in update["match_field"]["description"]
def test_api_mcp_schema_gives_every_field_concrete_chinese_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 all(re.search(r"[\u3400-\u9fff]", item) for item in descriptions)
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.USER
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_input_contract_truncates_long_field_description_without_leaking_values() -> None:
"""
超长字段说明应保持有界,且只回传合同文本。
"""
description = "字段说明 " * 40
schema = {
"oneOf": [{
"properties": {
"operation_id": {"const": "example.read"},
"query": {"type": "object", "properties": {
"keyword": {"type": "string", "description": description}
}},
}
}]
}
contract = api_input_contract("example.read", schema)
bounded = contract["query"]["fields"]["keyword"]["description"]
assert len(bounded) <= 180
assert bounded.endswith("…")
assert bounded == description[:179].rstrip() + "…"
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)
@pytest.mark.parametrize("channel", [NotificationChannel.QQ, NotificationChannel.Feishu])
@pytest.mark.parametrize("bound_suffix,other_suffix", [("openid", "userid"), ("userid", "openid")])
@pytest.mark.parametrize("other_value", [None, "", "different-id", "channel-id"])
def test_gateway_resolves_either_channel_binding(db, channel, bound_suffix, other_suffix, other_value) -> None:
"""
任一候选字段匹配即可绑定真实用户,另一字段缺失、为空或不同时仍可使用。
"""
prefix = channel.name.lower()
bindings = {f"{prefix}_{bound_suffix}": "channel-id"}
if other_value is not None:
bindings[f"{prefix}_{other_suffix}"] = other_value
user = db.add(User(name="channel-owner", is_active=True, is_superuser=False, settings=bindings))
users = TransactionalUserRepository(sync_session=SessionFactory, async_session=async_session_scope)
gateway = MoviePilotApiTool(
session_id="session", user_id="channel-id", data=SimpleNamespace(users=users),
)
gateway.set_message_attr(channel=channel.value, source="main-bot", username="channel-nickname")
identity = asyncio.run(gateway._resolve_api_identity())
assert identity == (str(user.id), "channel-owner", False)
@pytest.mark.parametrize("is_channel_admin", [False, True])
@pytest.mark.parametrize("user_id,username", [(11, "alice"), (12, "bob")])
@pytest.mark.parametrize(
"operation_id,arguments",
[
("subscription.add", {"body": {"name": "示例"}}),
("subscription.update", {"body": {"id": 1}}),
("subscription.delete", {"path_params": {"subscribe_id": 1}}),
],
)
def test_subscription_writes_keep_bound_channel_identity(
is_channel_admin, user_id, username, operation_id, arguments,
) -> None:
"""
不同飞书发送者的订阅写入使用各自绑定用户,不因渠道管理员资格提权。
"""
users = SimpleNamespace(
find_name_by_bindings=Mock(return_value=username),
async_get_by_name=AsyncMock(return_value=SimpleNamespace(
id=user_id, name=username, is_active=True, is_superuser=False,
)),
)
gateway = MoviePilotApiTool(
session_id="session", user_id=f"ou_{username}", data=SimpleNamespace(users=users),
)
gateway.set_message_attr(channel=NotificationChannel.Feishu.value, source="main-bot", username="")
gateway.set_agent_context({"is_admin": is_channel_admin})
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="moviepilot_api", arguments={"operation_id": operation_id}, requires_admin=False,
)
assert policy.required_role is PrincipalRole.USER
assert policy.confirmation is ConfirmationMode.REQUIRED
with patch("app.agent.tools.impl.api.MoviePilotApiExecutor") as executor_type, patch(
"app.application.security.auth.build_superuser_token_payload",
) as superuser:
executor_type.return_value.execute = AsyncMock(return_value='{"success": true}')
result = asyncio.run(gateway.run(operation_id=operation_id, **arguments))
assert json.loads(result)["success"] is True
context = executor_type.call_args.kwargs["context"]
assert (context.user_id, context.username, context.is_admin) == (str(user_id), username, False)
users.find_name_by_bindings.assert_called_once_with({
"feishu_userid": f"ou_{username}", "feishu_openid": f"ou_{username}",
}, match_any=True)
superuser.assert_not_called()
@pytest.mark.parametrize("is_bound", [False, True])
def test_subscription_write_rejects_unbound_or_inactive_channel_admin(is_bound) -> None:
"""
渠道管理员未绑定有效用户时不得回退超级管理员创建订阅。
"""
users = SimpleNamespace(
find_name_by_bindings=Mock(return_value="disabled" if is_bound else None),
async_get_by_name=AsyncMock(return_value=SimpleNamespace(is_active=False)),
)
gateway = MoviePilotApiTool(
session_id="session", user_id="ou_user", data=SimpleNamespace(users=users),
)
gateway.set_message_attr(channel=NotificationChannel.Feishu.value, source="main-bot", username="")
gateway.set_agent_context({"is_admin": True})
with patch("app.agent.tools.impl.api.MoviePilotApiExecutor") as executor_type, patch(
"app.application.security.auth.build_superuser_token_payload",
) as superuser:
result = asyncio.run(gateway.run(operation_id="subscription.add", body={"name": "示例"}))
assert json.loads(result)["error"] == "operation_unavailable"
executor_type.assert_not_called()
superuser.assert_not_called()
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",
]
def test_mcp_field_guidance_preserves_chinese_contracts_without_mutating_inputs() -> None:
"""
保留端点中文说明,替换英文兜底,且不改变字段约束或原始对象。
"""
schema = {
"type": "object",
"properties": {
"page": {"type": "integer", "minimum": 1, "default": 1, "description": "页码从 1 开始"},
"count": {"type": "integer", "maximum": 50, "description": "Page size."},
"keyword": {"type": "string"},
},
"required": ["page"],
"additionalProperties": False,
}
original = json.loads(json.dumps(schema))
rewritten = _apply_field_descriptions(schema)
assert schema == original
assert rewritten["properties"]["page"] == schema["properties"]["page"]
assert re.search(r"[\u3400-\u9fff]", rewritten["properties"]["count"]["description"])
assert re.search(r"[\u3400-\u9fff]", rewritten["properties"]["keyword"]["description"])
for field in ("count", "keyword"):
rewritten["properties"][field].pop("description")
original["properties"][field].pop("description", None)
assert rewritten == original
def test_mcp_field_guidance_rejects_unknown_undocumented_fields() -> None:
"""
未提供中文说明且没有登记兜底的字段必须失败,不能静默遗漏文档。
"""
with pytest.raises(ValueError, match="undocumented_field"):
_apply_field_descriptions({"properties": {"undocumented_field": {"type": "string"}}})
def test_workflow_plugin_actions_preserves_optional_nullable_plugin_filter() -> None:
"""
插件动作列表的筛选参数可省略或为 null,生成契约应与既有端点签名一致。
"""
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"]["const"] == "workflow.plugin.actions"
)
query = branch["properties"]["query"]
assert "query" not in branch["required"]
assert "plugin_id" not in query.get("required", [])
assert query["properties"]["plugin_id"]["anyOf"] == [{"type": "string"}, {"type": "null"}]