1. 新增QQBot、Wecom、Dingtalk、Instagram、WechatMp等渠道的单元测试夹具与测试用例 2. 修复WechatILink的缓存键顺序与测试用例 3. 补全Feishu适配器的日志断言与测试逻辑 4. 清理WechatILink冗余的clear_context_token测试代码
348 lines
10 KiB
Python
348 lines
10 KiB
Python
"""signature 模块单元测试。
|
||
|
||
覆盖 ``verify_webhook_signature``(Ed25519 签名校验)、``is_timestamp_valid``
|
||
(时间戳防重放窗口校验)、``is_callback_verification``(回调地址验证请求识别)
|
||
与 ``build_callback_verification_response``(回调验证响应构造)。
|
||
|
||
v1 场景下 QQ Bot 采用 WebSocket 长连接,无需 Webhook 签名校验;
|
||
本模块为 v2 Webhook 模式预留,测试验证 Ed25519 签名/验签逻辑与降级语义。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
import pytest
|
||
from yuxi.channels.plugins.qqbot import signature
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
|
||
def _generate_ed25519_keypair() -> tuple[str, str]:
|
||
"""生成 Ed25519 密钥对,返回 (private_key_pem, public_key_pem)。"""
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||
|
||
private_key = Ed25519PrivateKey.generate()
|
||
public_key = private_key.public_key()
|
||
private_pem = private_key.private_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PrivateFormat.PKCS8,
|
||
encryption_algorithm=serialization.NoEncryption(),
|
||
).decode()
|
||
public_pem = public_key.public_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||
).decode()
|
||
return private_pem, public_pem
|
||
|
||
|
||
def _sign_message(private_pem: str, message: str) -> str:
|
||
"""使用 Ed25519 私钥签名消息,返回十六进制签名。"""
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||
|
||
private_key = serialization.load_pem_private_key(
|
||
private_pem.encode(),
|
||
password=None,
|
||
)
|
||
assert isinstance(private_key, Ed25519PrivateKey)
|
||
sig_bytes = private_key.sign(message.encode())
|
||
return sig_bytes.hex()
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestVerifyWebhookSignature:
|
||
"""verify_webhook_signature Ed25519 签名校验测试。"""
|
||
|
||
def test_returns_false_when_cryptography_missing(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# Arrange - 模拟 cryptography 不可用
|
||
monkeypatch.setattr(signature, "_HAS_CRYPTOGRAPHY", False)
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature("123", "body", "sig", "pub")
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_valid_signature_returns_true(self) -> None:
|
||
# Arrange - 生成真实 Ed25519 密钥对并签名
|
||
private_pem, public_pem = _generate_ed25519_keypair()
|
||
timestamp = "1700000000"
|
||
body = '{"event":"test"}'
|
||
sig_hex = _sign_message(private_pem, f"{timestamp}{body}")
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature(timestamp, body, sig_hex, public_pem)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_invalid_signature_returns_false(self) -> None:
|
||
# Arrange
|
||
_private_pem, public_pem = _generate_ed25519_keypair()
|
||
timestamp = "1700000000"
|
||
body = '{"event":"test"}'
|
||
|
||
# Act - 使用错误的签名
|
||
result = signature.verify_webhook_signature(timestamp, body, "deadbeef", public_pem)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_invalid_public_key_pem_returns_false(self) -> None:
|
||
# Arrange
|
||
timestamp = "1700000000"
|
||
body = '{"event":"test"}'
|
||
|
||
# Act - 无效的 PEM 字符串
|
||
result = signature.verify_webhook_signature(timestamp, body, "aabb", "not-a-pem")
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_invalid_hex_signature_returns_false(self) -> None:
|
||
# Arrange
|
||
_private_pem, public_pem = _generate_ed25519_keypair()
|
||
timestamp = "1700000000"
|
||
body = '{"event":"test"}'
|
||
|
||
# Act - 非十六进制签名
|
||
result = signature.verify_webhook_signature(timestamp, body, "not-hex", public_pem)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_non_ed25519_public_key_returns_false(self) -> None:
|
||
# Arrange - 生成 RSA 公钥(非 Ed25519)
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
|
||
|
||
rsa_key = generate_private_key(public_exponent=65537, key_size=2048)
|
||
rsa_public_pem = (
|
||
rsa_key.public_key()
|
||
.public_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||
)
|
||
.decode()
|
||
)
|
||
|
||
# Act
|
||
result = signature.verify_webhook_signature("123", "body", "aabb", rsa_public_pem)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestIsTimestampValid:
|
||
"""is_timestamp_valid 时间戳防重放校验测试。"""
|
||
|
||
def test_current_timestamp_is_valid(self) -> None:
|
||
# Arrange
|
||
ts = str(int(time.time()))
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_expired_timestamp_is_invalid(self) -> None:
|
||
# Arrange - 1 小时前,超出 5 分钟窗口
|
||
ts = str(int(time.time()) - 3600)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_future_timestamp_within_skew_is_valid(self) -> None:
|
||
# Arrange - 1 分钟后,在 5 分钟窗口内
|
||
ts = str(int(time.time()) + 60)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_future_timestamp_beyond_skew_is_invalid(self) -> None:
|
||
# Arrange - 1 小时后,超出 5 分钟窗口
|
||
ts = str(int(time.time()) + 3600)
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_invalid_string_returns_false(self) -> None:
|
||
# Arrange / Act
|
||
result = signature.is_timestamp_valid("not-a-number")
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_none_returns_false(self) -> None:
|
||
# Arrange / Act
|
||
result = signature.is_timestamp_valid(None) # type: ignore[arg-type]
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_custom_max_skew_allows_older_timestamp(self) -> None:
|
||
# Arrange - 10 分钟前,默认窗口(5 分钟)会失败
|
||
ts = str(int(time.time()) - 600)
|
||
|
||
# Act - 自定义窗口 900 秒
|
||
result = signature.is_timestamp_valid(ts, max_skew_seconds=900)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_zero_skew_only_accepts_current_second(self) -> None:
|
||
# Arrange
|
||
ts = str(int(time.time()))
|
||
|
||
# Act
|
||
result = signature.is_timestamp_valid(ts, max_skew_seconds=0)
|
||
|
||
# Assert - 当前秒在 0 偏差内有效
|
||
assert result is True
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestIsCallbackVerification:
|
||
"""is_callback_verification 回调验证请求识别测试。"""
|
||
|
||
def test_returns_true_with_plain_token_and_event_ts(self) -> None:
|
||
# Arrange
|
||
payload = {"plain_token": "token-1", "event_ts": "1700000000"}
|
||
|
||
# Act
|
||
result = signature.is_callback_verification(payload)
|
||
|
||
# Assert
|
||
assert result is True
|
||
|
||
def test_returns_false_without_plain_token(self) -> None:
|
||
# Arrange
|
||
payload = {"event_ts": "1700000000"}
|
||
|
||
# Act
|
||
result = signature.is_callback_verification(payload)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_returns_false_without_event_ts(self) -> None:
|
||
# Arrange
|
||
payload = {"plain_token": "token-1"}
|
||
|
||
# Act
|
||
result = signature.is_callback_verification(payload)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_returns_false_for_normal_event(self) -> None:
|
||
# Arrange
|
||
payload = {"t": "C2C_MESSAGE_CREATE", "d": {"content": "hi"}}
|
||
|
||
# Act
|
||
result = signature.is_callback_verification(payload)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
def test_returns_false_for_empty_payload(self) -> None:
|
||
# Arrange
|
||
payload = {}
|
||
|
||
# Act
|
||
result = signature.is_callback_verification(payload)
|
||
|
||
# Assert
|
||
assert result is False
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestBuildCallbackVerificationResponse:
|
||
"""build_callback_verification_response 回调验证响应构造测试。"""
|
||
|
||
def test_returns_none_when_cryptography_missing(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# Arrange - 模拟 cryptography 不可用
|
||
monkeypatch.setattr(signature, "_HAS_CRYPTOGRAPHY", False)
|
||
|
||
# Act
|
||
result = signature.build_callback_verification_response(
|
||
"token-1",
|
||
"1700000000",
|
||
"private-pem",
|
||
)
|
||
|
||
# Assert
|
||
assert result is None
|
||
|
||
def test_returns_dict_with_valid_ed25519_key(self) -> None:
|
||
# Arrange
|
||
private_pem, _public_pem = _generate_ed25519_keypair()
|
||
plain_token = "token-1"
|
||
event_ts = "1700000000"
|
||
|
||
# Act
|
||
result = signature.build_callback_verification_response(
|
||
plain_token,
|
||
event_ts,
|
||
private_pem,
|
||
)
|
||
|
||
# Assert
|
||
assert result is not None
|
||
assert result["plain_token"] == plain_token
|
||
assert "signature" in result
|
||
assert len(result["signature"]) > 0
|
||
|
||
def test_returns_none_with_invalid_private_key(self) -> None:
|
||
# Arrange
|
||
# Act
|
||
result = signature.build_callback_verification_response(
|
||
"token-1",
|
||
"1700000000",
|
||
"not-a-valid-pem",
|
||
)
|
||
|
||
# Assert
|
||
assert result is None
|
||
|
||
def test_returns_none_with_non_ed25519_private_key(self) -> None:
|
||
# Arrange - 生成 RSA 私钥(非 Ed25519)
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
|
||
|
||
rsa_key = generate_private_key(public_exponent=65537, key_size=2048)
|
||
rsa_private_pem = rsa_key.private_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PrivateFormat.PKCS8,
|
||
encryption_algorithm=serialization.NoEncryption(),
|
||
).decode()
|
||
|
||
# Act
|
||
result = signature.build_callback_verification_response(
|
||
"token-1",
|
||
"1700000000",
|
||
rsa_private_pem,
|
||
)
|
||
|
||
# Assert
|
||
assert result is None
|