"""dingtalk signature 模块单元测试。 覆盖钉钉 HTTP 模式 Webhook 回调的签名计算与验签(设计方案 §3.3): - ``compute_signature``:HMAC-SHA256 + Base64 编码。 - ``verify_signature``:正确签名通过、错误签名失败、时间戳超时拒绝、 空值拒绝(恒定时间比较防时序攻击)。 - ``extract_timestamp_sign``:从 payload 提取 timestamp 与 sign, 字段缺失返回 ``(0, "")``,timestamp 非整数抛 ``ValidationError``。 钉钉签名规则:拼接 ``timestamp + "\\n" + sign_secret``,计算 HMAC-SHA256 并 Base64 编码;timestamp 与当前时间差超过 60 分钟(3600 秒)拒绝。 """ from __future__ import annotations import base64 import hashlib import hmac import time import pytest from yuxi.channels.contract.errors import ValidationError from yuxi.channels.plugins.dingtalk import signature pytestmark = pytest.mark.unit # ------------------------------------------------------------------ # compute_signature # ------------------------------------------------------------------ @pytest.mark.unit class TestComputeSignature: """compute_signature 签名计算测试。""" def test_returns_base64_string(self) -> None: # Arrange timestamp = 1700000000000 sign_secret = "my_secret" # Act result = signature.compute_signature(timestamp, sign_secret) # Assert - 应为可解码的 Base64 字符串 assert isinstance(result, str) decoded = base64.b64decode(result) assert len(decoded) == 32 # SHA256 摘要长度 def test_matches_expected_hmac_sha256(self) -> None: # Arrange timestamp = 1700000000000 sign_secret = "my_secret" string_to_sign = f"{timestamp}\n{sign_secret}" expected = base64.b64encode( hmac.new( string_to_sign.encode("utf-8"), digestmod=hashlib.sha256, ).digest() ).decode("utf-8") # Act result = signature.compute_signature(timestamp, sign_secret) # Assert assert result == expected def test_different_secret_produces_different_signature(self) -> None: # Arrange timestamp = 1700000000000 # Act sig1 = signature.compute_signature(timestamp, "secret1") sig2 = signature.compute_signature(timestamp, "secret2") # Assert assert sig1 != sig2 def test_different_timestamp_produces_different_signature(self) -> None: # Arrange sign_secret = "my_secret" # Act sig1 = signature.compute_signature(1700000000000, sign_secret) sig2 = signature.compute_signature(1700000000001, sign_secret) # Assert assert sig1 != sig2 # ------------------------------------------------------------------ # verify_signature # ------------------------------------------------------------------ @pytest.mark.unit class TestVerifySignature: """verify_signature 签名校验测试。""" def test_valid_signature_returns_true(self) -> None: # Arrange timestamp = int(time.time() * 1000) sign_secret = "my_secret" sign = signature.compute_signature(timestamp, sign_secret) # Act result = signature.verify_signature(timestamp, sign, sign_secret) # Assert assert result is True def test_invalid_signature_returns_false(self) -> None: # Arrange timestamp = int(time.time() * 1000) sign_secret = "my_secret" # Act result = signature.verify_signature(timestamp, "wrong-signature", sign_secret) # Assert assert result is False def test_tampered_timestamp_returns_false(self) -> None: # Arrange - 签名基于原 timestamp 计算,timestamp 被篡改 timestamp = int(time.time() * 1000) sign_secret = "my_secret" sign = signature.compute_signature(timestamp, sign_secret) # Act result = signature.verify_signature(timestamp + 1, sign, sign_secret) # Assert assert result is False def test_expired_timestamp_returns_false(self) -> None: # Arrange - timestamp 超出 60 分钟(3600 秒)窗口 now = int(time.time() * 1000) expired_ts = now - 3601 * 1000 # 3601 秒前 sign_secret = "my_secret" sign = signature.compute_signature(expired_ts, sign_secret) # Act result = signature.verify_signature(expired_ts, sign, sign_secret, now_ts=now) # Assert assert result is False def test_future_timestamp_beyond_tolerance_returns_false(self) -> None: # Arrange - timestamp 在未来超出 60 分钟窗口 now = int(time.time() * 1000) future_ts = now + 3601 * 1000 # 3601 秒后 sign_secret = "my_secret" sign = signature.compute_signature(future_ts, sign_secret) # Act result = signature.verify_signature(future_ts, sign, sign_secret, now_ts=now) # Assert assert result is False def test_timestamp_within_tolerance_returns_true(self) -> None: # Arrange - 100 秒前,在 3600 秒窗口内 now = int(time.time() * 1000) timestamp = now - 100 * 1000 sign_secret = "my_secret" sign = signature.compute_signature(timestamp, sign_secret) # Act result = signature.verify_signature(timestamp, sign, sign_secret, now_ts=now) # Assert assert result is True def test_empty_sign_returns_false(self) -> None: # Arrange timestamp = int(time.time() * 1000) # Act result = signature.verify_signature(timestamp, "", "secret") # Assert assert result is False def test_empty_sign_secret_returns_false(self) -> None: # Arrange timestamp = int(time.time() * 1000) # Act result = signature.verify_signature(timestamp, "sig", "") # Assert assert result is False def test_zero_timestamp_returns_false(self) -> None: # Arrange # Act result = signature.verify_signature(0, "sig", "secret") # Assert assert result is False def test_now_ts_none_uses_real_time(self) -> None: # Arrange - now_ts 为 None 时取真实时间,近期 timestamp 应通过 timestamp = int(time.time() * 1000) sign_secret = "my_secret" sign = signature.compute_signature(timestamp, sign_secret) # Act result = signature.verify_signature(timestamp, sign, sign_secret) # Assert assert result is True # ------------------------------------------------------------------ # extract_timestamp_sign # ------------------------------------------------------------------ @pytest.mark.unit class TestExtractTimestampSign: """extract_timestamp_sign 提取测试。""" def test_extracts_timestamp_and_sign(self) -> None: # Arrange payload = {"timestamp": 1700000000000, "sign": "abc123", "event": "msg"} # Act timestamp, sign = signature.extract_timestamp_sign(payload) # Assert assert timestamp == 1700000000000 assert sign == "abc123" def test_missing_timestamp_returns_zero(self) -> None: # Arrange payload = {"sign": "abc123"} # Act timestamp, sign = signature.extract_timestamp_sign(payload) # Assert assert timestamp == 0 assert sign == "abc123" def test_missing_sign_returns_empty_string(self) -> None: # Arrange payload = {"timestamp": 1700000000000} # Act timestamp, sign = signature.extract_timestamp_sign(payload) # Assert assert timestamp == 1700000000000 assert sign == "" def test_missing_both_returns_defaults(self) -> None: # Arrange payload = {"event": "msg"} # Act timestamp, sign = signature.extract_timestamp_sign(payload) # Assert assert timestamp == 0 assert sign == "" def test_empty_payload_returns_defaults(self) -> None: # Arrange / Act timestamp, sign = signature.extract_timestamp_sign({}) # Assert assert timestamp == 0 assert sign == "" def test_string_timestamp_converted_to_int(self) -> None: # Arrange - timestamp 为字符串形式 payload = {"timestamp": "1700000000000", "sign": "sig"} # Act timestamp, sign = signature.extract_timestamp_sign(payload) # Assert assert timestamp == 1700000000000 assert sign == "sig" def test_non_numeric_string_timestamp_raises_validation_error(self) -> None: # Arrange - timestamp 为非数字字符串 payload = {"timestamp": "not-a-number", "sign": "sig"} # Act / Assert with pytest.raises(ValidationError) as exc_info: signature.extract_timestamp_sign(payload) assert exc_info.value.field == "timestamp" def test_null_sign_normalized_to_empty_string(self) -> None: # Arrange - sign 为 None payload = {"timestamp": 1700000000000, "sign": None} # Act timestamp, sign = signature.extract_timestamp_sign(payload) # Assert assert timestamp == 1700000000000 assert sign == ""