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

348 lines
10 KiB
Python
Raw Normal View History

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