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

363 lines
11 KiB
Python
Raw Normal View History

"""signature 模块单元测试。
覆盖 Webhook 签名校验``verify_webhook_signature``时间戳防重放校验
``is_timestamp_valid``AES-256-CBC 事件解密``decrypt_event``
URL 验证 challenge 提取``extract_challenge``解密测试在
``cryptography`` 不可用时通过 monkeypatch 验证 DependencyError 路径
可用时验证实际解密行为
"""
from __future__ import annotations
import base64
import hashlib
import json
import time
import pytest
from yuxi.channels.contract.errors import DependencyError, ValidationError
from yuxi.channels.plugins.feishu import signature
pytestmark = pytest.mark.unit
# ------------------------------------------------------------------
# verify_webhook_signature
# ------------------------------------------------------------------
def _compute_signature(timestamp: str, nonce: str, encrypt_key: str, body: str) -> str:
"""计算飞书 Webhook 签名SHA256"""
raw = f"{timestamp}{nonce}{encrypt_key}{body}".encode()
return hashlib.sha256(raw).hexdigest()
@pytest.mark.unit
class TestVerifyWebhookSignature:
"""verify_webhook_signature 签名校验测试。"""
def test_valid_signature_returns_true(self) -> None:
# Arrange
timestamp = "1700000000"
nonce = "abc123"
encrypt_key = "my_key"
body = '{"event":"message"}'
signature_str = _compute_signature(timestamp, nonce, encrypt_key, body)
# Act
result = signature.verify_webhook_signature(
timestamp,
nonce,
encrypt_key,
body,
signature_str,
)
# Assert
assert result is True
def test_invalid_signature_returns_false(self) -> None:
# Arrange
timestamp = "1700000000"
nonce = "abc123"
encrypt_key = "my_key"
body = '{"event":"message"}'
# Act
result = signature.verify_webhook_signature(
timestamp,
nonce,
encrypt_key,
body,
"wrong_signature",
)
# Assert
assert result is False
def test_tampered_body_returns_false(self) -> None:
# Arrange - 签名基于原始 body 计算body 被篡改
timestamp = "1700000000"
nonce = "abc123"
encrypt_key = "my_key"
original_body = '{"event":"message"}'
tampered_body = '{"event":"edited"}'
signature_str = _compute_signature(timestamp, nonce, encrypt_key, original_body)
# Act
result = signature.verify_webhook_signature(
timestamp,
nonce,
encrypt_key,
tampered_body,
signature_str,
)
# Assert
assert result is False
def test_type_error_returns_false_without_raising(self) -> None:
# Arrange - 非字符串输入导致 TypeError应返回 False 不抛异常
# Act
result = signature.verify_webhook_signature(
None,
"nonce",
"key",
"body",
"sig", # type: ignore[arg-type]
)
# Assert
assert result is False
def test_empty_strings_returns_false(self) -> None:
# Arrange - 空字符串 body 与不匹配的签名
# Act
result = signature.verify_webhook_signature(
"",
"",
"",
"",
"some_sig",
)
# Assert - 空字符串可编码,但签名不匹配
assert result is False
def test_empty_strings_with_correct_signature_returns_true(self) -> None:
# Arrange - 空字符串可正确计算签名
sig = _compute_signature("", "", "", "")
# Act
result = signature.verify_webhook_signature("", "", "", "", sig)
# Assert
assert result is True
# ------------------------------------------------------------------
# is_timestamp_valid
# ------------------------------------------------------------------
@pytest.mark.unit
class TestIsTimestampValid:
"""is_timestamp_valid 时间戳防重放测试。"""
def test_current_timestamp_valid(self) -> None:
# Arrange
ts = str(int(time.time()))
# Act
result = signature.is_timestamp_valid(ts)
# Assert
assert result is True
def test_timestamp_within_skew_window_valid(self) -> None:
# Arrange - 100 秒前,在 300 秒窗口内
ts = str(int(time.time()) - 100)
# Act
result = signature.is_timestamp_valid(ts)
# Assert
assert result is True
def test_timestamp_future_within_skew_valid(self) -> None:
# Arrange - 100 秒后,在 300 秒窗口内
ts = str(int(time.time()) + 100)
# Act
result = signature.is_timestamp_valid(ts)
# Assert
assert result is True
def test_expired_timestamp_invalid(self) -> None:
# Arrange - 600 秒前,超出 300 秒窗口
ts = str(int(time.time()) - 600)
# Act
result = signature.is_timestamp_valid(ts)
# Assert
assert result is False
def test_custom_max_skew_window(self) -> None:
# Arrange - 50 秒前,使用 30 秒窗口
ts = str(int(time.time()) - 50)
# Act
result = signature.is_timestamp_valid(ts, max_skew_seconds=30)
# Assert
assert result is False
def test_invalid_format_returns_false(self) -> None:
# Arrange - 非数字字符串
# Act
result = signature.is_timestamp_valid("not-a-timestamp")
# Assert
assert result is False
def test_none_input_returns_false(self) -> None:
# Arrange / Act
result = signature.is_timestamp_valid(None) # type: ignore[arg-type]
# Assert
assert result is False
# ------------------------------------------------------------------
# decrypt_event
# ------------------------------------------------------------------
def _encrypt_payload(encrypt_key: str, plaintext_dict: dict) -> str:
"""使用 AES-256-CBC 加密构造测试用 payload。"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding as sympad
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = hashlib.sha256(encrypt_key.encode("utf-8")).digest()[:32]
iv = b"\x00" * 16
plaintext = json.dumps(plaintext_dict).encode("utf-8")
padder = sympad.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
return base64.b64encode(iv + ciphertext).decode("utf-8")
@pytest.mark.unit
class TestDecryptEvent:
"""decrypt_event AES-256-CBC 解密测试。"""
def test_cryptography_unavailable_raises_dependency_error(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange - 模拟 cryptography 不可用
monkeypatch.setattr(signature, "_HAS_CRYPTOGRAPHY", False)
# Act / Assert
with pytest.raises(DependencyError) as exc_info:
signature.decrypt_event("payload", "key")
assert exc_info.value.dep == "cryptography"
@pytest.mark.asyncio
async def test_valid_decryption_returns_dict(self) -> None:
# Arrange - 需要 cryptography
pytest.importorskip("cryptography")
encrypt_key = "test_encrypt_key"
original = {"event": "message", "data": "hello"}
payload = _encrypt_payload(encrypt_key, original)
# Act
result = signature.decrypt_event(payload, encrypt_key)
# Assert
assert result == original
def test_invalid_base64_raises_validation_error(self) -> None:
# Arrange
pytest.importorskip("cryptography")
# Act / Assert
with pytest.raises(ValidationError) as exc_info:
signature.decrypt_event("!!!not-valid-base64!!!", "key")
assert exc_info.value.field == "encrypt_payload"
assert exc_info.value.message == "decrypt_failed"
def test_invalid_json_raises_validation_error(self) -> None:
# Arrange - 解密成功但 JSON 解析失败
pytest.importorskip("cryptography")
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding as sympad
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
encrypt_key = "key"
key = hashlib.sha256(encrypt_key.encode("utf-8")).digest()[:32]
iv = b"\x00" * 16
plaintext = b"not-json-at-all"
padder = sympad.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
payload = base64.b64encode(iv + ciphertext).decode("utf-8")
# Act / Assert
with pytest.raises(ValidationError):
signature.decrypt_event(payload, encrypt_key)
def test_validation_error_preserves_cause(self) -> None:
# Arrange
pytest.importorskip("cryptography")
# Act / Assert - ValidationError 通过 raise ... from exc 链式保留原始异常
with pytest.raises(ValidationError) as exc_info:
signature.decrypt_event("!!!invalid!!!", "key")
assert exc_info.value.__cause__ is not None
# ------------------------------------------------------------------
# extract_challenge
# ------------------------------------------------------------------
@pytest.mark.unit
class TestExtractChallenge:
"""extract_challenge URL 验证 challenge 提取测试。"""
def test_valid_challenge_string_returned(self) -> None:
# Arrange
payload = {"challenge": "ajksdfhkjdsfhkj", "token": "xxx"}
# Act
result = signature.extract_challenge(payload)
# Assert
assert result == "ajksdfhkjdsfhkj"
def test_missing_challenge_returns_none(self) -> None:
# Arrange
payload = {"event": "message"}
# Act
result = signature.extract_challenge(payload)
# Assert
assert result is None
def test_non_string_challenge_returns_none(self) -> None:
# Arrange - challenge 为数字
payload = {"challenge": 12345}
# Act
result = signature.extract_challenge(payload)
# Assert
assert result is None
def test_empty_payload_returns_none(self) -> None:
# Arrange
# Act
result = signature.extract_challenge({})
# Assert
assert result is None
def test_empty_string_challenge_returned(self) -> None:
# Arrange - 空字符串仍是有效字符串
payload = {"challenge": ""}
# Act
result = signature.extract_challenge(payload)
# Assert
assert result == ""