"""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 = "corp1hello" # 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 = "" # 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 = "deeptop" # 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 = "hello wecom" 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 = "msg" 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