ForcePilot/backend/test/unit/channels/plugins/dingtalk/test_signature.py

306 lines
9.2 KiB
Python
Raw Normal View History

"""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 == ""