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