1. 新增QQBot、Wecom、Dingtalk、Instagram、WechatMp等渠道的单元测试夹具与测试用例 2. 修复WechatILink的缓存键顺序与测试用例 3. 补全Feishu适配器的日志断言与测试逻辑 4. 清理WechatILink冗余的clear_context_token测试代码
440 lines
13 KiB
Python
440 lines
13 KiB
Python
"""signature 模块单元测试。
|
||
|
||
覆盖企业微信 Webhook 签名校验(``verify_webhook_signature``)、时间戳防重放
|
||
校验(``is_timestamp_valid``)、URL 验证 echostr 提取(``extract_echostr``)、
|
||
AES-CBC 事件解密(``decrypt_event``)与 XML 解析(``_xml_to_dict``)。
|
||
|
||
企业微信签名算法与飞书不同:
|
||
- 签名:``SHA1(token + timestamp + nonce + encrypt_msg)``(排序后拼接)
|
||
- 解密:AES-CBC + PKCS7 块大小 32 字节(企业微信自定义),含 16 字节随机串 +
|
||
4 字节消息长度 + 消息体 + CorpID 结构
|
||
- 密钥:EncodingAESKey(43 位)Base64 解码后补 ``=`` 得到 32 字节 AES key
|
||
|
||
解密测试在 ``cryptography`` 不可用时通过 monkeypatch 验证 DependencyError 路径,
|
||
可用时验证实际解密行为。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import struct
|
||
import time
|
||
|
||
import pytest
|
||
from yuxi.channels.contract.errors import DependencyError, ValidationError
|
||
from yuxi.channels.plugins.wecom import signature
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# verify_webhook_signature
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def _compute_wecom_signature(
|
||
token: str,
|
||
timestamp: str,
|
||
nonce: str,
|
||
encrypt_msg: str,
|
||
) -> str:
|
||
"""计算企业微信 Webhook 签名(SHA1)。
|
||
|
||
算法:``SHA1("".join(sorted([token, timestamp, nonce, encrypt_msg])))``
|
||
"""
|
||
parts = sorted([token, timestamp, nonce, encrypt_msg])
|
||
raw = "".join(parts).encode("utf-8")
|
||
return hashlib.sha1(raw).hexdigest()
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestVerifyWebhookSignature:
|
||
"""verify_webhook_signature 签名校验测试。"""
|
||
|
||
def test_valid_signature_returns_true(self) -> None:
|
||
# Arrange
|
||
token = "my_token"
|
||
timestamp = "1700000000"
|
||
nonce = "abc123"
|
||
encrypt_msg = "encrypted_content"
|
||
msg_signature = _compute_wecom_signature(token, timestamp, nonce, encrypt_msg)
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature(
|
||
token,
|
||
timestamp,
|
||
nonce,
|
||
encrypt_msg,
|
||
msg_signature,
|
||
)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_invalid_signature_returns_false(self) -> None:
|
||
# Arrange
|
||
# Act
|
||
result = signature.verify_webhook_signature(
|
||
"token",
|
||
"1700000000",
|
||
"nonce",
|
||
"encrypt",
|
||
"wrong_signature",
|
||
)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_tampered_encrypt_msg_returns_false(self) -> None:
|
||
# Arrange - 签名基于原始 encrypt_msg 计算,encrypt_msg 被篡改
|
||
token = "tok"
|
||
timestamp = "1700000000"
|
||
nonce = "n1"
|
||
original_msg = "original"
|
||
tampered_msg = "tampered"
|
||
msg_signature = _compute_wecom_signature(token, timestamp, nonce, original_msg)
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature(
|
||
token,
|
||
timestamp,
|
||
nonce,
|
||
tampered_msg,
|
||
msg_signature,
|
||
)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_type_error_returns_false_without_raising(self) -> None:
|
||
# Arrange - 非字符串输入导致 TypeError,应返回 False 不抛异常
|
||
# Act
|
||
result = signature.verify_webhook_signature(
|
||
None, # type: ignore[arg-type]
|
||
"ts",
|
||
"nonce",
|
||
"msg",
|
||
"sig",
|
||
)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_empty_strings_with_correct_signature_returns_true(self) -> None:
|
||
# Arrange - 空字符串可正确计算签名
|
||
sig = _compute_wecom_signature("", "", "", "")
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature("", "", "", "", sig)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_constant_time_comparison_used(self) -> None:
|
||
"""验证使用 hmac.compare_digest 进行恒定时间比对。"""
|
||
# Arrange - 确保恒定时间比对函数被调用(不抛异常即可)
|
||
token = "t"
|
||
timestamp = "1"
|
||
nonce = "n"
|
||
encrypt_msg = "e"
|
||
sig = _compute_wecom_signature(token, timestamp, nonce, encrypt_msg)
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature(
|
||
token,
|
||
timestamp,
|
||
nonce,
|
||
encrypt_msg,
|
||
sig,
|
||
)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# is_timestamp_valid
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestIsTimestampValid:
|
||
"""is_timestamp_valid 时间戳防重放测试。"""
|
||
|
||
def test_current_timestamp_valid(self) -> None:
|
||
# Arrange
|
||
ts = str(int(time.time()))
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_timestamp_within_skew_window_valid(self) -> None:
|
||
# Arrange - 100 秒前,在 300 秒窗口内
|
||
ts = str(int(time.time()) - 100)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_timestamp_future_within_skew_valid(self) -> None:
|
||
# Arrange - 100 秒后,在 300 秒窗口内
|
||
ts = str(int(time.time()) + 100)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_expired_timestamp_invalid(self) -> None:
|
||
# Arrange - 600 秒前,超出 300 秒窗口
|
||
ts = str(int(time.time()) - 600)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_custom_max_skew_window(self) -> None:
|
||
# Arrange - 50 秒前,使用 30 秒窗口
|
||
ts = str(int(time.time()) - 50)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts, max_skew_seconds=30)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_non_numeric_returns_false(self) -> None:
|
||
# Arrange - 非数字字符串
|
||
# Act
|
||
result = signature.is_timestamp_valid("not-a-timestamp")
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_none_input_returns_false(self) -> None:
|
||
# Arrange / Act
|
||
result = signature.is_timestamp_valid(None) # type: ignore[arg-type]
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# extract_echostr
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestExtractEchostr:
|
||
"""extract_echostr URL 验证 echostr 提取测试。"""
|
||
|
||
def test_valid_echostr_returned(self) -> None:
|
||
# Arrange
|
||
payload = {"echostr": "abc123encrypted", "token": "xxx"}
|
||
|
||
# Act
|
||
result = signature.extract_echostr(payload)
|
||
|
||
# Assert
|
||
assert result == "abc123encrypted"
|
||
|
||
def test_missing_echostr_returns_none(self) -> None:
|
||
# Arrange
|
||
payload = {"event": "message"}
|
||
|
||
# Act
|
||
result = signature.extract_echostr(payload)
|
||
|
||
# Assert
|
||
assert result is None
|
||
|
||
def test_non_string_echostr_returns_none(self) -> None:
|
||
# Arrange - echostr 为数字
|
||
payload = {"echostr": 12345}
|
||
|
||
# Act
|
||
result = signature.extract_echostr(payload)
|
||
|
||
# Assert
|
||
assert result is None
|
||
|
||
def test_empty_payload_returns_none(self) -> None:
|
||
# Arrange / Act
|
||
result = signature.extract_echostr({})
|
||
|
||
# Assert
|
||
assert result is None
|
||
|
||
def test_empty_string_echostr_returns_none(self) -> None:
|
||
# Arrange - 空字符串 echostr 应返回 None(falsy)
|
||
# Act
|
||
result = signature.extract_echostr({"echostr": ""})
|
||
|
||
# Assert
|
||
assert result is None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# _xml_to_dict
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestXmlToDict:
|
||
"""_xml_to_dict XML 解析测试。"""
|
||
|
||
def test_valid_xml_returns_dict(self) -> None:
|
||
# Arrange
|
||
xml_str = "<xml><ToUserName>corp1</ToUserName><Content>hello</Content></xml>"
|
||
|
||
# Act
|
||
result = signature._xml_to_dict(xml_str)
|
||
|
||
# Assert
|
||
assert result == {"ToUserName": "corp1", "Content": "hello"}
|
||
|
||
def test_xml_with_empty_element_returns_empty_string(self) -> None:
|
||
# Arrange - 空元素 text 为 None 时应返回空字符串
|
||
xml_str = "<xml><EmptyField></EmptyField></xml>"
|
||
|
||
# Act
|
||
result = signature._xml_to_dict(xml_str)
|
||
|
||
# Assert
|
||
assert result == {"EmptyField": ""}
|
||
|
||
def test_invalid_xml_raises_validation_error(self) -> None:
|
||
# Arrange - 非 XML 字符串
|
||
xml_str = "not xml at all"
|
||
|
||
# Act / Assert
|
||
with pytest.raises(ValidationError) as exc_info:
|
||
signature._xml_to_dict(xml_str)
|
||
assert exc_info.value.field == "xml"
|
||
|
||
def test_xml_with_nested_elements(self) -> None:
|
||
# Arrange - 多层 XML(仅取直接子元素)
|
||
xml_str = "<xml><Item><Sub>deep</Sub></Item><Name>top</Name></xml>"
|
||
|
||
# Act
|
||
result = signature._xml_to_dict(xml_str)
|
||
|
||
# Assert - 直接子元素的 text 为空(有子元素时 text 为 None → "")
|
||
assert "Item" in result
|
||
assert result["Name"] == "top"
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# decrypt_event
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def _encode_aes_key_for_test() -> str:
|
||
"""构造 43 位 EncodingAESKey(Base64 编码的 32 字节密钥去掉末尾 =)。"""
|
||
key_bytes = b"\x01" * 32
|
||
encoded = base64.b64encode(key_bytes).decode("utf-8")
|
||
return encoded.rstrip("=")
|
||
|
||
|
||
def _encrypt_wecom_payload(
|
||
encoding_aes_key: str,
|
||
corp_id: str,
|
||
xml_content: str,
|
||
) -> str:
|
||
"""使用企业微信 AES-CBC 算法加密构造测试用 payload。
|
||
|
||
加密结构:Base64(16 字节随机串 + 4 字节消息长度(大端) + 消息体 + CorpID)
|
||
PKCS7 填充块大小 32 字节。
|
||
"""
|
||
pytest.importorskip("cryptography")
|
||
from cryptography.hazmat.backends import default_backend
|
||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||
|
||
key = base64.b64decode(encoding_aes_key + "=")
|
||
iv = b"\x00" * 16
|
||
msg_bytes = xml_content.encode("utf-8")
|
||
corp_bytes = corp_id.encode("utf-8")
|
||
random_bytes = b"\x01" * 16
|
||
msg_len = struct.pack(">I", len(msg_bytes))
|
||
content = random_bytes + msg_len + msg_bytes + corp_bytes
|
||
|
||
# PKCS7 填充,块大小 32
|
||
pad_len = 32 - (len(content) % 32)
|
||
if pad_len == 0:
|
||
pad_len = 32
|
||
padded = content + bytes([pad_len]) * pad_len
|
||
|
||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||
encryptor = cipher.encryptor()
|
||
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
||
return base64.b64encode(iv + ciphertext).decode("utf-8")
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestDecryptEvent:
|
||
"""decrypt_event AES-CBC 解密测试。"""
|
||
|
||
def test_cryptography_unavailable_raises_dependency_error(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# Arrange - 模拟 cryptography 不可用
|
||
monkeypatch.setattr(signature, "_HAS_CRYPTOGRAPHY", False)
|
||
|
||
# Act / Assert
|
||
with pytest.raises(DependencyError) as exc_info:
|
||
signature.decrypt_event("payload", "key", "corp_id")
|
||
assert exc_info.value.dep == "cryptography"
|
||
|
||
def test_valid_decryption_returns_dict(self) -> None:
|
||
# Arrange - 需要 cryptography
|
||
pytest.importorskip("cryptography")
|
||
encoding_aes_key = _encode_aes_key_for_test()
|
||
corp_id = "corp_test"
|
||
xml_content = "<xml><Content>hello wecom</Content></xml>"
|
||
payload = _encrypt_wecom_payload(encoding_aes_key, corp_id, xml_content)
|
||
|
||
# Act
|
||
result = signature.decrypt_event(payload, encoding_aes_key, corp_id)
|
||
|
||
# Assert
|
||
assert result == {"Content": "hello wecom"}
|
||
|
||
def test_invalid_base64_raises_validation_error(self) -> None:
|
||
# Arrange
|
||
pytest.importorskip("cryptography")
|
||
|
||
# Act / Assert
|
||
with pytest.raises(ValidationError) as exc_info:
|
||
signature.decrypt_event("!!!not-valid-base64!!!", "key", "corp_id")
|
||
assert exc_info.value.field == "encrypt_payload"
|
||
|
||
def test_corp_id_mismatch_raises_validation_error(self) -> None:
|
||
# Arrange - 解密成功但 CorpID 不匹配
|
||
pytest.importorskip("cryptography")
|
||
encoding_aes_key = _encode_aes_key_for_test()
|
||
xml_content = "<xml><Content>msg</Content></xml>"
|
||
payload = _encrypt_wecom_payload(encoding_aes_key, "correct_corp", xml_content)
|
||
|
||
# Act / Assert - 使用错误的 corp_id 解密
|
||
with pytest.raises(ValidationError):
|
||
signature.decrypt_event(payload, encoding_aes_key, "wrong_corp")
|
||
|
||
def test_validation_error_preserves_cause(self) -> None:
|
||
# Arrange
|
||
pytest.importorskip("cryptography")
|
||
|
||
# Act / Assert - ValidationError 通过 raise ... from exc 链式保留原始异常
|
||
with pytest.raises(ValidationError) as exc_info:
|
||
signature.decrypt_event("!!!invalid!!!", "key", "corp_id")
|
||
assert exc_info.value.__cause__ is not None
|