ForcePilot/backend/test/unit/channels/plugins/wecom/test_signature.py
Kris 532e7c59ec test: add and fix multi-channel unit tests
1. 新增QQBot、Wecom、Dingtalk、Instagram、WechatMp等渠道的单元测试夹具与测试用例
2. 修复WechatILink的缓存键顺序与测试用例
3. 补全Feishu适配器的日志断言与测试逻辑
4. 清理WechatILink冗余的clear_context_token测试代码
2026-07-08 22:59:24 +08:00

440 lines
13 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.

"""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 结构
- 密钥EncodingAESKey43 位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 应返回 Nonefalsy
# 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 位 EncodingAESKeyBase64 编码的 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