mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
396 lines
16 KiB
Python
396 lines
16 KiB
Python
import asyncio
|
|
import json
|
|
import unittest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.agent.tools.catalog import ToolCatalogSnapshot
|
|
from app.agent.tools.factory import MoviePilotToolFactory
|
|
from app.agent.tools.impl._system_setting_utils import list_setting_specs
|
|
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
|
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
|
|
from app.agent.tools.manager import MoviePilotToolsManager
|
|
from app.runtime.config import Settings, settings
|
|
from app.schemas.types import SystemConfigKey
|
|
|
|
|
|
class TestAgentSystemSettingsTools(unittest.TestCase):
|
|
def test_query_system_settings_returns_exact_systemconfig_value(self):
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.query_system_settings.SystemConfigOper"
|
|
) as system_config_oper:
|
|
system_config_oper.return_value.get.return_value = [{"name": "qb", "enabled": True}]
|
|
result = asyncio.run(tool.run(setting_key="Downloaders"))
|
|
|
|
payload = json.loads(result)
|
|
self.assertTrue(payload["success"])
|
|
self.assertEqual(payload["matched_count"], 1)
|
|
self.assertEqual(payload["settings"][0]["setting_key"], "Downloaders")
|
|
self.assertEqual(payload["settings"][0]["value"][0]["name"], "qb")
|
|
system_config_oper.return_value.get.assert_called_once_with(
|
|
SystemConfigKey.Downloaders
|
|
)
|
|
|
|
def test_query_system_settings_redacts_secret_values_by_default(self):
|
|
"""查询系统设置默认应脱敏 API Key、Token、Cookie 等敏感字段。"""
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.query_system_settings.SystemConfigOper"
|
|
) as system_config_oper:
|
|
system_config_oper.return_value.get.return_value = [
|
|
{
|
|
"name": "site-a",
|
|
"apikey": "site-api-key",
|
|
"token": "site-token",
|
|
"cookie": "uid=1; passkey=secret",
|
|
"url": "https://example.com",
|
|
}
|
|
]
|
|
result = asyncio.run(
|
|
tool.run(setting_key="UserSiteAuthParams", include_values=True)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
item = payload["settings"][0]
|
|
self.assertTrue(item["redacted"])
|
|
self.assertFalse(payload["show_secrets"])
|
|
self.assertEqual("***", item["value"][0]["apikey"])
|
|
self.assertEqual("***", item["value"][0]["token"])
|
|
self.assertEqual("***", item["value"][0]["cookie"])
|
|
self.assertEqual("https://example.com", item["value"][0]["url"])
|
|
|
|
def test_query_system_settings_show_secrets_requires_admin_context(self):
|
|
"""只有管理员显式请求 show_secrets 时才返回敏感配置原值。"""
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
|
|
tool.set_agent_context({"is_admin": True})
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.query_system_settings.SystemConfigOper"
|
|
) as system_config_oper:
|
|
system_config_oper.return_value.get.return_value = [
|
|
{"name": "site-a", "apikey": "site-api-key"}
|
|
]
|
|
result = asyncio.run(
|
|
tool.run(
|
|
setting_key="UserSiteAuthParams",
|
|
include_values=True,
|
|
show_secrets=True,
|
|
)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
item = payload["settings"][0]
|
|
self.assertTrue(payload["show_secrets"])
|
|
self.assertFalse(item["redacted"])
|
|
self.assertEqual("site-api-key", item["value"][0]["apikey"])
|
|
|
|
def test_agent_tool_refuses_unconfirmed_secret_read(self):
|
|
"""Agent 宿主要求确认时,工具自身也不得直接返回密钥。"""
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
tool.set_agent_context(
|
|
{
|
|
"is_admin": True,
|
|
"require_secret_confirmation": True,
|
|
"secret_read_confirmed": False,
|
|
}
|
|
)
|
|
|
|
with patch.object(
|
|
QuerySystemSettingsTool,
|
|
"_load_setting_value",
|
|
return_value="must-not-load",
|
|
) as load_value:
|
|
result = asyncio.run(
|
|
tool.run(setting_key="TMDB_API_KEY", show_secrets=True)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertFalse(payload["success"])
|
|
self.assertIn("确认", payload["message"])
|
|
load_value.assert_not_called()
|
|
|
|
def test_agent_tool_ignores_forged_confirmation_context(self):
|
|
"""模型可影响的共享上下文不得伪造宿主确认。"""
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
tool.set_agent_context(
|
|
{
|
|
"is_admin": True,
|
|
"require_secret_confirmation": True,
|
|
"secret_read_confirmed": True,
|
|
}
|
|
)
|
|
|
|
with patch.object(
|
|
QuerySystemSettingsTool,
|
|
"_load_setting_value",
|
|
return_value="must-not-load",
|
|
) as load_value:
|
|
result = asyncio.run(
|
|
tool.run(setting_key="TMDB_API_KEY", show_secrets=True)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertFalse(payload["success"])
|
|
load_value.assert_not_called()
|
|
|
|
def test_query_system_settings_group_defaults_to_summary_for_multiple_items(self):
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.query_system_settings.SystemConfigOper"
|
|
) as system_config_oper:
|
|
system_config_oper.return_value.get.return_value = []
|
|
result = asyncio.run(tool.run(group="systemconfig"))
|
|
|
|
payload = json.loads(result)
|
|
self.assertTrue(payload["success"])
|
|
self.assertFalse(payload["include_values"])
|
|
self.assertGreater(payload["matched_count"], 1)
|
|
|
|
def test_settings_group_retains_all_basic_settings(self):
|
|
"""settings 分组应继续完整列出基础 Settings 字段。"""
|
|
specs = list_setting_specs(group="settings")
|
|
|
|
self.assertSetEqual(
|
|
{spec.key for spec in specs},
|
|
set(Settings.model_fields),
|
|
)
|
|
self.assertTrue(all(spec.source == "settings" for spec in specs))
|
|
|
|
def test_query_system_settings_ai_agent_group_spans_both_setting_sources(self):
|
|
"""AI Agent 分组应同时返回基础运行配置和 SystemConfig 扩展配置。"""
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
expected_values = {
|
|
"AI_AGENT_ENABLE": True,
|
|
"LLM_PROVIDER": "openai",
|
|
"LLM_MODEL": "gpt-test",
|
|
"LLM_THINKING_LEVEL": "high",
|
|
"LLM_API_KEY": "llm-secret",
|
|
"AUDIO_INPUT_PROVIDER": "openai",
|
|
"AUDIO_OUTPUT_PROVIDER": "openai",
|
|
"AI_RECOMMEND_ENABLED": True,
|
|
"AIAgentConfig": {"chatgpt": {"enabled": True}},
|
|
"AIAgentMcpServers": [],
|
|
}
|
|
|
|
with patch.object(
|
|
QuerySystemSettingsTool,
|
|
"_load_setting_value",
|
|
side_effect=lambda spec: expected_values.get(spec.key),
|
|
):
|
|
result = asyncio.run(
|
|
tool.run(group="ai_agent", include_values=True)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
items = {
|
|
item["setting_key"]: item
|
|
for item in payload["settings"]
|
|
}
|
|
|
|
self.assertTrue(payload["success"])
|
|
self.assertEqual(items["AI_AGENT_ENABLE"]["source"], "settings")
|
|
self.assertIs(items["AI_AGENT_ENABLE"]["value"], True)
|
|
self.assertEqual(items["LLM_PROVIDER"]["value"], "openai")
|
|
self.assertEqual(items["LLM_MODEL"]["value"], "gpt-test")
|
|
self.assertEqual(items["LLM_THINKING_LEVEL"]["value"], "high")
|
|
self.assertTrue(items["LLM_API_KEY"]["redacted"])
|
|
self.assertEqual(items["LLM_API_KEY"]["value"], "***")
|
|
self.assertEqual(items["AUDIO_INPUT_PROVIDER"]["value"], "openai")
|
|
self.assertEqual(items["AUDIO_OUTPUT_PROVIDER"]["value"], "openai")
|
|
self.assertIs(items["AI_RECOMMEND_ENABLED"]["value"], True)
|
|
self.assertEqual(items["AIAgentConfig"]["source"], "systemconfig")
|
|
self.assertEqual(items["AIAgentMcpServers"]["source"], "systemconfig")
|
|
|
|
def test_update_system_settings_merges_dict_and_emits_event(self):
|
|
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
|
|
config_oper = MagicMock()
|
|
config_oper.get.side_effect = [
|
|
{"chatgpt": {"enabled": True}},
|
|
{"chatgpt": {"enabled": False}, "gemini": {"enabled": True}},
|
|
]
|
|
config_oper.async_set = AsyncMock(return_value=True)
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.update_system_settings.SystemConfigOper",
|
|
return_value=config_oper,
|
|
), patch(
|
|
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
|
new=AsyncMock(),
|
|
) as send_event:
|
|
result = asyncio.run(
|
|
tool.run(
|
|
setting_key="AIAgentConfig",
|
|
operation="merge_dict",
|
|
value={"chatgpt": {"enabled": False}, "gemini": {"enabled": True}},
|
|
)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertTrue(payload["success"])
|
|
self.assertTrue(payload["changed"])
|
|
config_oper.async_set.assert_awaited_once_with(
|
|
SystemConfigKey.AIAgentConfig,
|
|
{"chatgpt": {"enabled": False}, "gemini": {"enabled": True}},
|
|
)
|
|
send_event.assert_awaited_once()
|
|
|
|
def test_update_system_settings_upserts_named_list_item(self):
|
|
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
|
|
config_oper = MagicMock()
|
|
config_oper.get.side_effect = [
|
|
[{"name": "qb", "enabled": False}],
|
|
[{"name": "qb", "enabled": True}],
|
|
]
|
|
config_oper.async_set = AsyncMock(return_value=True)
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.update_system_settings.SystemConfigOper",
|
|
return_value=config_oper,
|
|
), patch(
|
|
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
|
new=AsyncMock(),
|
|
):
|
|
result = asyncio.run(
|
|
tool.run(
|
|
setting_key="downloaders",
|
|
operation="upsert_list_item",
|
|
value={"name": "qb", "enabled": True},
|
|
)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertTrue(payload["success"])
|
|
self.assertEqual(payload["saved_value"], [{"name": "qb", "enabled": True}])
|
|
config_oper.async_set.assert_awaited_once_with(
|
|
SystemConfigKey.Downloaders,
|
|
[{"name": "qb", "enabled": True}],
|
|
)
|
|
|
|
def test_update_system_settings_redacts_secret_values_in_response(self):
|
|
"""更新敏感系统设置后响应不应回显旧值和新值中的密钥。"""
|
|
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
|
|
config_oper = MagicMock()
|
|
config_oper.get.side_effect = [
|
|
[{"name": "site-a", "apikey": "old-key"}],
|
|
[{"name": "site-a", "apikey": "new-key"}],
|
|
]
|
|
config_oper.async_set = AsyncMock(return_value=True)
|
|
|
|
with patch(
|
|
"app.agent.tools.impl.update_system_settings.SystemConfigOper",
|
|
return_value=config_oper,
|
|
), patch(
|
|
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
|
new=AsyncMock(),
|
|
):
|
|
result = asyncio.run(
|
|
tool.run(
|
|
setting_key="UserSiteAuthParams",
|
|
operation="upsert_list_item",
|
|
value={"name": "site-a", "apikey": "new-key"},
|
|
match_field="name",
|
|
)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertTrue(payload["success"])
|
|
self.assertTrue(payload["values_redacted"])
|
|
self.assertEqual("***", payload["previous_value"][0]["apikey"])
|
|
self.assertEqual("***", payload["saved_value"][0]["apikey"])
|
|
|
|
def test_update_system_settings_updates_basic_settings(self):
|
|
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
|
|
|
|
# settings 是 pydantic 模型实例,不能直接 patch 其实例方法(__setattr__ 会拦截),
|
|
# 改 patch 类上的方法;经实例调用时不带 self,断言参数不受影响。
|
|
with patch.object(
|
|
type(settings),
|
|
"update_setting",
|
|
return_value=(True, ""),
|
|
) as update_setting, patch.object(
|
|
UpdateSystemSettingsTool,
|
|
"_load_setting_value",
|
|
side_effect=["https://old.example.com", "https://new.example.com"],
|
|
), patch(
|
|
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
|
|
new=AsyncMock(),
|
|
) as send_event:
|
|
result = asyncio.run(
|
|
tool.run(setting_key="APP_DOMAIN", value="https://new.example.com")
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertTrue(payload["success"])
|
|
self.assertTrue(payload["changed"])
|
|
update_setting.assert_called_once_with("APP_DOMAIN", "https://new.example.com")
|
|
send_event.assert_awaited_once()
|
|
|
|
def test_tool_manager_blocks_admin_tools_for_non_admin_context(self):
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
catalog = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=0, factory_revision="test"
|
|
)
|
|
|
|
with patch.object(
|
|
MoviePilotToolFactory,
|
|
"create_catalog",
|
|
return_value=catalog,
|
|
):
|
|
manager = MoviePilotToolsManager(is_admin=False)
|
|
result = asyncio.run(
|
|
manager.call_tool(
|
|
"query_system_settings",
|
|
{"setting_key": "Downloaders"},
|
|
)
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
self.assertIn("error", payload)
|
|
self.assertIn("系统管理员", payload["error"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("setting_key", "should_redact"),
|
|
[
|
|
("API_TOKEN", True),
|
|
("LLM_API_KEY", True),
|
|
("COOKIECLOUD_KEY", True),
|
|
("COOKIECLOUD_AUTH_HEADER", True),
|
|
("SUPERUSER_PASSWORD", True),
|
|
("DB_POSTGRESQL_PASSWORD", True),
|
|
("GITHUB_TOKEN", True),
|
|
("FEISHU_VERIFICATION_TOKEN", True),
|
|
("SECRET_KEY", True),
|
|
("RESOURCE_SECRET_KEY", True),
|
|
("PROJECT_NAME", False),
|
|
("ACCESS_TOKEN_EXPIRE_MINUTES", False),
|
|
("LLM_MAX_CONTEXT_TOKENS", False),
|
|
("COOKIECLOUD_INTERVAL", False),
|
|
],
|
|
)
|
|
def test_query_system_settings_uses_precise_secret_identity_matrix(
|
|
setting_key: str,
|
|
should_redact: bool,
|
|
) -> None:
|
|
"""设置查询应隐藏真实凭据,同时保留仅名称相似的普通设置。"""
|
|
marker = "credential-marker" if should_redact else "visible-marker"
|
|
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
|
|
|
with patch.object(
|
|
QuerySystemSettingsTool,
|
|
"_load_setting_value",
|
|
return_value=marker,
|
|
):
|
|
payload = json.loads(asyncio.run(tool.run(setting_key=setting_key)))
|
|
|
|
item = payload["settings"][0]
|
|
assert item["redacted"] is should_redact
|
|
assert item["value"] == ("***" if should_redact else marker)
|
|
if should_redact:
|
|
assert marker not in json.dumps(payload)
|