ForcePilot/backend/test/unit/channels/plugins/qqbot/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

348 lines
10 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 模块单元测试。
覆盖 ``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