fix(synologychat): keep incomplete clients safely disabled

This commit is contained in:
jxxghp
2026-10-17 05:15:35 +08:00
parent 72a526e366
commit 65698fd9fb
2 changed files with 191 additions and 11 deletions

View File

@@ -1,7 +1,7 @@
import json
import re
from threading import Lock
from typing import List, Optional
from typing import List, Optional, cast
from urllib.parse import quote
from app.adapters.network.http import RequestUtils
@@ -15,7 +15,13 @@ lock = Lock()
class SynologyChat:
def __init__(self, SYNOLOGYCHAT_WEBHOOK: Optional[str] = None, SYNOLOGYCHAT_TOKEN: Optional[str] = None, **kwargs):
"""管理群晖 Chat 机器人凭据并发送文本和资源列表。"""
def __init__(self, SYNOLOGYCHAT_WEBHOOK: Optional[str] = None, SYNOLOGYCHAT_TOKEN: Optional[str] = None, **kwargs: object) -> None:
"""先建立禁用状态,只有凭据完整时才创建 HTTP 客户端。"""
self._webhook_url = ""
self._token: Optional[str] = None
self._domain: Optional[str] = None
if not SYNOLOGYCHAT_WEBHOOK or not SYNOLOGYCHAT_TOKEN:
logger.error("SynologyChat配置不完整!")
return
@@ -25,8 +31,9 @@ class SynologyChat:
if self._webhook_url:
self._domain = url_tools.base_url(self._webhook_url)
def check_token(self, token: str) -> bool:
return True if token == self._token else False
def check_token(self, token: Optional[str]) -> bool:
"""仅接受已配置机器人令牌,缺失配置和缺失入站令牌均不能通过认证。"""
return self._token is not None and token == self._token
def get_state(self) -> bool:
"""
@@ -39,7 +46,7 @@ class SynologyChat:
return True
return False
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
def send_msg(self, title: Optional[str], text: Optional[str] = None, image: Optional[str] = None,
userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
"""
发送SynologyChat消息
@@ -68,12 +75,13 @@ class SynologyChat:
if text:
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
else:
caption = title
# 正文为空时,上方检查已保证标题存在。
caption = cast(str, title)
if link:
caption = f"{caption}\n[查看详情]({link})"
payload_data = {'text': quote(caption)}
payload_data: dict[str, object] = {'text': quote(caption)}
if image:
payload_data['file_url'] = quote(image)
if userid:
@@ -102,6 +110,7 @@ class SynologyChat:
try:
if not title or not isinstance(medias, list):
return False
image: Optional[str]
index, image, caption = 1, "", "*%s*" % title
for media in medias:
if not image:
@@ -121,11 +130,12 @@ class SynologyChat:
f"类型:{media.type.value}")
index += 1
userids: List[object]
if userid:
userids = [int(userid)]
else:
userids = self.__get_bot_users()
payload_data = {
payload_data: dict[str, object] = {
"text": quote(caption),
"user_ids": userids
}
@@ -169,12 +179,13 @@ class SynologyChat:
if link:
caption = f"{caption}\n[查看详情]({link})"
userids: List[object]
if userid:
userids = [int(userid)]
else:
userids = self.__get_bot_users()
payload_data = {
payload_data: dict[str, object] = {
"text": quote(caption),
"user_ids": userids
}
@@ -183,7 +194,7 @@ class SynologyChat:
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
return False
def __get_bot_users(self):
def __get_bot_users(self) -> List[object]:
"""
查询机器人可见的用户列表
"""
@@ -199,7 +210,7 @@ class SynologyChat:
else:
return []
def __send_request(self, payload_data):
def __send_request(self, payload_data: dict[str, object]) -> bool:
"""
发送消息请求
"""

View File

@@ -0,0 +1,169 @@
"""群晖 Chat 空配置、令牌校验与消息负载兼容契约。"""
import json
from types import SimpleNamespace
from unittest.mock import Mock
from urllib.parse import unquote
import pytest
import app.modules.synologychat.synologychat as client_module
from app.modules.synologychat import SynologyChatModule
from app.modules.synologychat.synologychat import SynologyChat
@pytest.mark.parametrize(
"webhook,token",
[(None, None), ("https://chat.invalid/hook", None), (None, "secret"),
("", "secret"), ("https://chat.invalid/hook", "")],
)
def test_incomplete_configuration_stays_disabled(monkeypatch, webhook, token):
"""配置缺项时所有公开状态和发送入口均安全返回,且任何令牌都不能通过。"""
transport = Mock()
monkeypatch.setattr(client_module, "RequestUtils", transport)
client = SynologyChat(SYNOLOGYCHAT_WEBHOOK=webhook, SYNOLOGYCHAT_TOKEN=token)
assert client.get_state() is False
assert client.check_token(None) is False
assert client.check_token("secret") is False
assert client.send_msg("title") is False
assert client.send_medias_msg([object()], title="title") is False
assert client.send_torrents_msg([object()], title="title") is None
transport.assert_not_called()
@pytest.fixture
def configured_client(monkeypatch):
"""使用本地响应替身建立已配置客户端,禁止真实网络请求。"""
transport = Mock()
transport.post_res.return_value = SimpleNamespace(
status_code=200, json=lambda: {"success": True}, text="", reason="OK",
)
transport.get_res.return_value = SimpleNamespace(
status_code=200, json=lambda: {"data": {"users": [{"user_id": 7, "deleted": False}]}},
)
monkeypatch.setattr(client_module, "RequestUtils", Mock(return_value=transport))
client = SynologyChat(
SYNOLOGYCHAT_WEBHOOK="https://chat.invalid/hook", SYNOLOGYCHAT_TOKEN="secret",
)
return client, transport
@pytest.mark.parametrize("token,expected", [(None, False), ("wrong", False), ("secret", True)])
def test_only_configured_token_is_accepted(configured_client, token, expected):
"""保留精确令牌匹配,不接受缺失或错误令牌。"""
client, transport = configured_client
assert client.check_token(token) is expected
transport.post_res.assert_not_called()
transport.get_res.assert_not_called()
@pytest.mark.parametrize(
"title,text,caption",
[("head", None, "head"), (None, "body", "*None*\nbody"),
("head\nbody", None, "*head*\nbody"), ("head", "body\n\nline", "*head*\nbody\nline")],
)
def test_text_format_and_explicit_recipient_are_preserved(configured_client, title, text, caption):
"""可空标题和多行正文保持原格式,显式用户 ID 仍按整数编码。"""
client, transport = configured_client
assert client.send_msg(title, text=text, userid="17") is True
payload = json.loads(transport.post_res.call_args.kwargs["data"].removeprefix("payload="))
assert unquote(payload["text"]) == caption
assert payload["user_ids"] == [17]
transport.get_res.assert_not_called()
def test_empty_message_does_not_send(configured_client):
"""标题正文均为空时仍直接拒绝发送。"""
client, transport = configured_client
assert client.send_msg(None) is False
transport.get_res.assert_not_called()
transport.post_res.assert_not_called()
def test_bot_user_values_and_deleted_filter_are_preserved(configured_client):
"""可见用户的原始 ID 保持原值,只有 deleted 严格为 False 的条目被选中。"""
client, transport = configured_client
transport.get_res.return_value.json = lambda: {
"data": {"users": [
{"user_id": 7, "deleted": False},
{"user_id": "8", "deleted": False},
{"deleted": False},
{"user_id": 9, "deleted": True},
{"user_id": 10, "deleted": 0},
]},
}
assert client.get_state() is True
assert client.send_msg("title") is True
payload = json.loads(transport.post_res.call_args.kwargs["data"].removeprefix("payload="))
assert payload["user_ids"] == [7, "8", None]
class _IntUserId:
"""保留旧插件可能传入的整数转换协议。"""
def __int__(self):
"""返回用于入站消息路由的用户 ID。"""
return 17
class _IndexUserId:
"""覆盖 int 支持的索引转换协议。"""
def __index__(self):
"""返回用于入站消息路由的用户 ID。"""
return 17
@pytest.fixture
def incoming_parser(configured_client, monkeypatch):
"""把入站解析器连接到内存配置和已配置的本地客户端。"""
client, _ = configured_client
module = SynologyChatModule()
conf = SimpleNamespace(name="chat", config={})
monkeypatch.setattr(module, "get_config", lambda _name: conf)
monkeypatch.setattr(module, "get_instance", lambda _name: client)
def parse(**fields):
"""用正常消息默认值构造表单,允许覆盖待验证字段。"""
form = {"token": "secret", "user_id": "17", "text": "hello", **fields}
return module.message_parser("chat", None, form, None)
return parse
@pytest.mark.parametrize(
"value,expected",
[(17, 17), ("17", 17), (b"17", 17), (bytearray(b"17"), 17),
(memoryview(b"17"), 17), (17.9, 17), (True, 1), (_IntUserId(), 17), (_IndexUserId(), 17)],
)
def test_user_id_conversion_protocols_remain_supported(incoming_parser, value, expected):
"""保留 int 原先接受的字符串、缓冲区和数值协议,不因类型检查缩窄正常输入。"""
result = incoming_parser(user_id=value)
assert result is not None
assert result.userid == expected
assert result.text == "hello"
@pytest.mark.parametrize("value", [None, object(), "not-a-number", float("inf")])
def test_invalid_user_ids_still_drop_message(incoming_parser, value):
"""不支持或不能完成整数转换的 ID 仍不能生成入站消息。"""
assert incoming_parser(user_id=value) is None
@pytest.mark.parametrize("token", [None, b"secret", "wrong", []])
def test_invalid_incoming_tokens_still_drop_message(incoming_parser, token):
"""解析入口拒绝缺失、非字符串或错误令牌。"""
assert incoming_parser(token=token) is None
def test_falsy_text_preserves_attachment_message(incoming_parser):
"""原先视为空文本的值仍允许有效附件作为消息内容。"""
result = incoming_parser(text=0, file_url="https://chat.invalid/image.png")
assert result is not None
assert result.text == ""
assert result.images[0].ref == "https://chat.invalid/image.png"
def test_truthy_non_text_still_drops_message(incoming_parser):
"""缺少字符串操作的非空文本值仍被拒绝。"""
assert incoming_parser(text=["bad"]) is None