Files
MoviePilot/tests/test_agent_system_settings_tools.py
InfinityPacer a6e8ba8b57 feat(agent): 敏感设置读取增加宿主二次确认 (#6283)
* feat(agent): require confirmation for secret settings

* fix(agent): preserve history during secret confirmation
2026-08-13 08:10:32 +08:00

390 lines
15 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 unittest
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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.core.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")
with patch(
"app.agent.tools.manager.MoviePilotToolFactory.create_tools",
return_value=[tool],
):
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)