refactor(webhook,tests): 完善webhook日志与多适配器测试修复
1. webhook路由新增记录请求源IP与请求头 2. 修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑 3. 新增Nostr适配器测试通用fixture 4. 更新NextcloudTalk签名相关测试与函数命名
This commit is contained in:
parent
1f78c44b03
commit
fbe47b6393
@ -76,6 +76,10 @@ async def webhook_dispatch(channel_type: str, request: Request):
|
||||
|
||||
body = await request.body()
|
||||
headers = dict(request.headers)
|
||||
source_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
adapter._last_webhook_headers = headers
|
||||
adapter._last_source_ip = source_ip
|
||||
|
||||
max_body_bytes = getattr(adapter, "_MAX_WEBHOOK_BODY_BYTES", 256 * 1024)
|
||||
if len(body) > max_body_bytes:
|
||||
|
||||
164
backend/test/unit/channels/conftest.py
Normal file
164
backend/test/unit/channels/conftest.py
Normal file
@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channels.adapters.nostr.config import NostrConfig
|
||||
from yuxi.channels.adapters.nostr.guard import GuardPolicy
|
||||
from yuxi.channels.models import (
|
||||
ChannelIdentity,
|
||||
ChannelResponse,
|
||||
ChannelType,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config() -> NostrConfig:
|
||||
return NostrConfig(
|
||||
relays=["wss://test.relay"],
|
||||
dm_policy="open",
|
||||
streaming_mode="block",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_crypto():
|
||||
crypto = MagicMock()
|
||||
crypto.npub = "npub1test0000000000000000000000000000000000000000000000000000"
|
||||
crypto.pubkey_hex.return_value = "a" * 64
|
||||
crypto.verify_event.return_value = True
|
||||
crypto.build_and_sign_event.return_value = {
|
||||
"id": "e" * 64,
|
||||
"pubkey": "a" * 64,
|
||||
"kind": 1,
|
||||
"content": "test_content",
|
||||
"tags": [],
|
||||
"created_at": int(time.time()),
|
||||
"sig": "s" * 128,
|
||||
}
|
||||
crypto.decrypt_nip04.return_value = "decrypted nip04"
|
||||
crypto.decrypt_nip17 = AsyncMock(return_value="decrypted nip17")
|
||||
crypto.encrypt_nip04.return_value = "encrypted_nip04_content"
|
||||
crypto.encrypt_nip17 = AsyncMock(return_value=json.dumps({
|
||||
"id": "g" * 64,
|
||||
"kind": 1059,
|
||||
"content": "encrypted_nip17_content",
|
||||
"tags": [["p", "r" * 64]],
|
||||
}))
|
||||
return crypto
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_relay_manager():
|
||||
manager = MagicMock()
|
||||
manager.broadcast = AsyncMock(return_value=2)
|
||||
manager.connect_all = AsyncMock()
|
||||
manager.disconnect_all = AsyncMock()
|
||||
manager.active_count = MagicMock(return_value=(1, 2))
|
||||
manager.query = AsyncMock(return_value=[])
|
||||
manager.send_auth = AsyncMock(return_value={})
|
||||
manager.on_connect = MagicMock()
|
||||
manager.on_disconnect = MagicMock()
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_guard_policy() -> GuardPolicy:
|
||||
return GuardPolicy(
|
||||
allowed_kinds={1, 4, 5, 7, 1059},
|
||||
dm_policy="open",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_kind1_event() -> dict:
|
||||
return {
|
||||
"id": "a" * 64,
|
||||
"pubkey": "b" * 64,
|
||||
"kind": 1,
|
||||
"content": "hello world",
|
||||
"tags": [],
|
||||
"created_at": 1715000000,
|
||||
"sig": "c" * 128,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_kind4_event() -> dict:
|
||||
return {
|
||||
"id": "d" * 64,
|
||||
"pubkey": "e" * 64,
|
||||
"kind": 4,
|
||||
"content": "encrypted_content_here",
|
||||
"tags": [["p", "f" * 64]],
|
||||
"created_at": 1715000000,
|
||||
"sig": "g" * 128,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_kind5_dm_event() -> dict:
|
||||
return {
|
||||
"id": "h" * 64,
|
||||
"pubkey": "i" * 64,
|
||||
"kind": 5,
|
||||
"content": "deleted",
|
||||
"tags": [["e", "j" * 64], ["p", "k" * 64]],
|
||||
"created_at": 1715000000,
|
||||
"sig": "l" * 128,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_kind5_group_event() -> dict:
|
||||
return {
|
||||
"id": "m" * 64,
|
||||
"pubkey": "n" * 64,
|
||||
"kind": 5,
|
||||
"content": "deleted",
|
||||
"tags": [["e", "o" * 64]],
|
||||
"created_at": 1715000000,
|
||||
"sig": "p" * 128,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_kind7_reaction_event() -> dict:
|
||||
return {
|
||||
"id": "q" * 64,
|
||||
"pubkey": "r" * 64,
|
||||
"kind": 7,
|
||||
"content": "+",
|
||||
"tags": [["e", "s" * 64], ["p", "t" * 64]],
|
||||
"created_at": 1715000000,
|
||||
"sig": "u" * 128,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_kind1059_event() -> dict:
|
||||
return {
|
||||
"id": "v" * 64,
|
||||
"pubkey": "w" * 64,
|
||||
"kind": 1059,
|
||||
"content": json.dumps({"kind": 9, "content": "nip17 encrypted"}),
|
||||
"tags": [["p", "x" * 64]],
|
||||
"created_at": 1715000000,
|
||||
"sig": "y" * 128,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_response() -> ChannelResponse:
|
||||
return ChannelResponse(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="nostr",
|
||||
channel_type=ChannelType.NOSTR,
|
||||
channel_user_id="sender_hex",
|
||||
channel_chat_id="dm:receiver_hex",
|
||||
),
|
||||
content="hello nostr",
|
||||
)
|
||||
@ -1891,32 +1891,34 @@ class TestThreadBindingManager:
|
||||
|
||||
class TestDirectRoom:
|
||||
def test_promote_room_to_direct_returns_status(self):
|
||||
from yuxi.channels.adapters.matrix.direct_room import promote_room_to_direct
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_adata = MagicMock()
|
||||
mock_adata.content = {}
|
||||
mock_client.get_account_data = AsyncMock(return_value=mock_adata)
|
||||
mock_client.set_account_data = AsyncMock()
|
||||
|
||||
adapter = MatrixAdapter({})
|
||||
adapter._client = mock_client
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
promote_room_to_direct(mock_client, "!room:example.org", "@alice:example.org")
|
||||
adapter.promote_room_to_direct("!room:example.org", "@alice:example.org")
|
||||
)
|
||||
assert result["status"] == "promoted"
|
||||
|
||||
def test_demote_direct_room(self):
|
||||
from yuxi.channels.adapters.matrix.direct_room import demote_direct_room
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_adata = MagicMock()
|
||||
mock_adata.content = {"@alice:example.org": ["!room:example.org"]}
|
||||
mock_client.get_account_data = AsyncMock(return_value=mock_adata)
|
||||
mock_client.set_account_data = AsyncMock()
|
||||
|
||||
adapter = MatrixAdapter({})
|
||||
adapter._client = mock_client
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
demote_direct_room(mock_client, "!room:example.org", "@alice:example.org")
|
||||
adapter.demote_direct_room("!room:example.org", "@alice:example.org")
|
||||
)
|
||||
assert result["status"] == "demoted"
|
||||
|
||||
|
||||
@ -959,23 +959,25 @@ class TestPairingCodeNormalization:
|
||||
assert _normalize_code("ABC123") == "ABC123"
|
||||
assert _normalize_code("other:ABC123") == "other:ABC123"
|
||||
|
||||
def test_approve_with_prefix(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_with_prefix(self):
|
||||
from yuxi.channels.adapters.slack.pairing import PairingManager
|
||||
|
||||
mgr = PairingManager(ttl_seconds=600)
|
||||
decision = mgr.generate_pairing("U001")
|
||||
decision = await mgr.generate_pairing("U001")
|
||||
assert decision.pairing_code is not None
|
||||
|
||||
user_id = mgr.approve(f"slack:{decision.pairing_code}")
|
||||
user_id = await mgr.approve(f"slack:{decision.pairing_code}")
|
||||
assert user_id == "U001"
|
||||
assert mgr.is_approved("U001")
|
||||
|
||||
def test_approve_without_prefix(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_without_prefix(self):
|
||||
from yuxi.channels.adapters.slack.pairing import PairingManager
|
||||
|
||||
mgr = PairingManager(ttl_seconds=600)
|
||||
decision = mgr.generate_pairing("U002")
|
||||
user_id = mgr.approve(decision.pairing_code)
|
||||
decision = await mgr.generate_pairing("U002")
|
||||
user_id = await mgr.approve(decision.pairing_code)
|
||||
assert user_id == "U002"
|
||||
|
||||
|
||||
|
||||
@ -552,6 +552,7 @@ class TestSecurityChannelSlug:
|
||||
policy = DiscordSecurityPolicy({
|
||||
"groupPolicy": "allowlist",
|
||||
"requireMention": False,
|
||||
"dangerouslyAllowNameMatching": True,
|
||||
"guilds": {
|
||||
"123": {"allowChannels": ["#general"]},
|
||||
},
|
||||
@ -568,6 +569,7 @@ class TestSecurityChannelSlug:
|
||||
policy = DiscordSecurityPolicy({
|
||||
"groupPolicy": "allowlist",
|
||||
"requireMention": False,
|
||||
"dangerouslyAllowNameMatching": True,
|
||||
"guilds": {
|
||||
"123": {"allowChannels": ["#general"]},
|
||||
},
|
||||
@ -601,6 +603,7 @@ class TestSecurityGuildSlug:
|
||||
policy = DiscordSecurityPolicy({
|
||||
"groupPolicy": "allowlist",
|
||||
"requireMention": False,
|
||||
"dangerouslyAllowNameMatching": True,
|
||||
"guilds": {
|
||||
"my-server": {"allowUsers": ["999"]},
|
||||
},
|
||||
@ -617,6 +620,7 @@ class TestSecurityGuildSlug:
|
||||
policy = DiscordSecurityPolicy({
|
||||
"groupPolicy": "allowlist",
|
||||
"requireMention": False,
|
||||
"dangerouslyAllowNameMatching": True,
|
||||
"guilds": {
|
||||
"My-Server": {"allowUsers": ["999"]},
|
||||
},
|
||||
|
||||
@ -1192,61 +1192,76 @@ class TestAliasSupport:
|
||||
|
||||
|
||||
class TestHMACSigning:
|
||||
def test_compute_hmac_signature(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
|
||||
def test_compute_bot_signature(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
|
||||
sig = _compute_hmac_signature(b"test body", "my-secret")
|
||||
sig = _compute_bot_signature(b"test body", "random-hex-32bytes-nonce", "my-secret")
|
||||
assert isinstance(sig, str)
|
||||
assert len(sig) > 0
|
||||
|
||||
def test_compute_hmac_deterministic(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
|
||||
def test_compute_bot_signature_deterministic(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
|
||||
sig1 = _compute_hmac_signature(b"hello", "secret")
|
||||
sig2 = _compute_hmac_signature(b"hello", "secret")
|
||||
sig1 = _compute_bot_signature(b"hello", "fixed-random", "secret")
|
||||
sig2 = _compute_bot_signature(b"hello", "fixed-random", "secret")
|
||||
assert sig1 == sig2
|
||||
|
||||
def test_compute_hmac_different_body_different_sig(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
|
||||
def test_compute_bot_signature_different_body_different_sig(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
|
||||
sig1 = _compute_hmac_signature(b"hello", "secret")
|
||||
sig2 = _compute_hmac_signature(b"world", "secret")
|
||||
sig1 = _compute_bot_signature(b"hello", "fixed-random", "secret")
|
||||
sig2 = _compute_bot_signature(b"world", "fixed-random", "secret")
|
||||
assert sig1 != sig2
|
||||
|
||||
def test_compute_hmac_different_secret_different_sig(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
|
||||
def test_compute_bot_signature_different_secret_different_sig(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
|
||||
sig1 = _compute_hmac_signature(b"hello", "secret1")
|
||||
sig2 = _compute_hmac_signature(b"hello", "secret2")
|
||||
sig1 = _compute_bot_signature(b"hello", "fixed-random", "secret1")
|
||||
sig2 = _compute_bot_signature(b"hello", "fixed-random", "secret2")
|
||||
assert sig1 != sig2
|
||||
|
||||
def test_compute_bot_signature_different_nonce_different_sig(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
|
||||
sig1 = _compute_bot_signature(b"hello", "random-aaa", "secret")
|
||||
sig2 = _compute_bot_signature(b"hello", "random-bbb", "secret")
|
||||
assert sig1 != sig2
|
||||
|
||||
def test_verify_hmac_signature_valid(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature, verify_hmac_signature
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature, verify_hmac_signature
|
||||
|
||||
sig = _compute_hmac_signature(b"test body", "my-secret")
|
||||
assert verify_hmac_signature(b"test body", sig, "my-secret") is True
|
||||
random_hex = "test-random-nonce"
|
||||
sig = _compute_bot_signature(b"test body", random_hex, "my-secret")
|
||||
assert verify_hmac_signature(b"test body", random_hex, sig, "my-secret") is True
|
||||
|
||||
def test_verify_hmac_signature_invalid(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
|
||||
|
||||
assert verify_hmac_signature(b"test body", "invalid-sig", "my-secret") is False
|
||||
assert verify_hmac_signature(b"test body", "random", "invalid-sig", "my-secret") is False
|
||||
|
||||
def test_verify_hmac_signature_empty_random(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
|
||||
|
||||
assert verify_hmac_signature(b"test body", "", "some-sig", "my-secret") is False
|
||||
|
||||
def test_verify_hmac_signature_empty_header(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
|
||||
|
||||
assert verify_hmac_signature(b"test body", "", "my-secret") is False
|
||||
assert verify_hmac_signature(b"test body", "random", "", "my-secret") is False
|
||||
|
||||
def test_verify_hmac_signature_empty_secret(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
|
||||
|
||||
assert verify_hmac_signature(b"test body", "some-sig", "") is False
|
||||
assert verify_hmac_signature(b"test body", "random", "some-sig", "") is False
|
||||
|
||||
def test_client_sign_body_with_secret(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
|
||||
|
||||
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass", bot_secret="secret")
|
||||
headers = client._sign_body(b"hello")
|
||||
assert "X-Nextcloud-Talk-SHA256" in headers
|
||||
assert "X-Nextcloud-Talk-Bot-Random" in headers
|
||||
assert "X-Nextcloud-Talk-Bot-Signature" in headers
|
||||
assert len(headers["X-Nextcloud-Talk-Bot-Random"]) == 64
|
||||
|
||||
def test_client_sign_body_without_secret(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
|
||||
@ -1259,15 +1274,16 @@ class TestHMACSigning:
|
||||
class TestWebhookSignature:
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_webhook_signature_valid(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
|
||||
|
||||
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
|
||||
body = b'{"test": "data"}'
|
||||
sig = _compute_hmac_signature(body, "my-secret")
|
||||
random_hex = "test-nonce-for-webhook"
|
||||
sig = _compute_bot_signature(body, random_hex, "my-secret")
|
||||
|
||||
result = await adapter.verify_webhook_signature(
|
||||
{"X-Nextcloud-Talk-SHA256": sig}, body
|
||||
{"X-Nextcloud-Talk-Bot-Signature": sig, "X-Nextcloud-Talk-Bot-Random": random_hex}, body
|
||||
)
|
||||
assert result is True
|
||||
|
||||
@ -1278,7 +1294,7 @@ class TestWebhookSignature:
|
||||
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
|
||||
|
||||
result = await adapter.verify_webhook_signature(
|
||||
{"X-Nextcloud-Talk-SHA256": "bad-signature"}, b"body"
|
||||
{"X-Nextcloud-Talk-Bot-Signature": "bad-signature", "X-Nextcloud-Talk-Bot-Random": "random"}, b"body"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@ -1298,21 +1314,22 @@ class TestWebhookSignature:
|
||||
adapter = NextcloudTalkAdapter({})
|
||||
|
||||
result = await adapter.verify_webhook_signature(
|
||||
{"X-Nextcloud-Talk-SHA256": "some-sig"}, b"body"
|
||||
{"X-Nextcloud-Talk-Bot-Signature": "some-sig", "X-Nextcloud-Talk-Bot-Random": "random"}, b"body"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_webhook_signature_lowercase_header(self):
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
|
||||
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
|
||||
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
|
||||
|
||||
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
|
||||
body = b"hello"
|
||||
sig = _compute_hmac_signature(body, "my-secret")
|
||||
random_hex = "lowercase-test-nonce"
|
||||
sig = _compute_bot_signature(body, random_hex, "my-secret")
|
||||
|
||||
result = await adapter.verify_webhook_signature(
|
||||
{"x-nextcloud-talk-sha256": sig}, body
|
||||
{"x-nextcloud-talk-bot-signature": sig, "x-nextcloud-talk-bot-random": random_hex}, body
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ class TestSynologyChatAdapter:
|
||||
|
||||
def test_default_config_values(self):
|
||||
adapter = SynologyChatAdapter(make_adapter_config())
|
||||
assert adapter.text_chunk_limit == 2000
|
||||
assert adapter.text_chunk_limit == 4000
|
||||
assert adapter.supports_markdown is True
|
||||
assert adapter.supports_streaming is True
|
||||
assert adapter.streaming_modes == ["block"]
|
||||
|
||||
@ -1066,26 +1066,27 @@ class TestSummarizer:
|
||||
result = summ.is_summarization_request("hello world")
|
||||
assert result is False
|
||||
|
||||
def test_auto_detect(self):
|
||||
import asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_detect(self):
|
||||
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
||||
from yuxi.channels.adapters.urbit.history import MessageCache
|
||||
|
||||
cache = MessageCache()
|
||||
asyncio.run(cache.cache_message("m1", "chat-1", "hello", "zod"))
|
||||
await cache.cache_message("m1", "chat-1", "hello", "zod")
|
||||
|
||||
summ = Summarizer(cache)
|
||||
info = summ.auto_detect_and_respond("chat-1", "catch up")
|
||||
info = await summ.auto_detect_and_respond("chat-1", "catch up")
|
||||
assert info is not None
|
||||
assert info.get("channel_id") == "chat-1"
|
||||
|
||||
def test_auto_detect_no_match(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_detect_no_match(self):
|
||||
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
||||
from yuxi.channels.adapters.urbit.history import MessageCache
|
||||
|
||||
cache = MessageCache()
|
||||
summ = Summarizer(cache)
|
||||
info = summ.auto_detect_and_respond("chat-1", "hello world")
|
||||
info = await summ.auto_detect_and_respond("chat-1", "hello world")
|
||||
assert info is None
|
||||
|
||||
|
||||
@ -1139,10 +1140,12 @@ class TestApprovalPersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_to_store(self):
|
||||
from yuxi.channels.adapters.urbit.approval import ApprovalSystem
|
||||
from yuxi.channels.adapters.urbit.settings import SettingsStore
|
||||
|
||||
approval = ApprovalSystem(owner_ship="~zod")
|
||||
approval.create_pending("dm", "~dev", "chat-1", "hello")
|
||||
await approval.persist_to_settings_store(self._make_mock_client())
|
||||
store = SettingsStore(self._make_mock_client())
|
||||
await approval.persist_to_settings_store(store)
|
||||
|
||||
|
||||
class TestApprovalDedupIntegration:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user