refactor(webhook,tests): 完善webhook日志与多适配器测试修复

1.  webhook路由新增记录请求源IP与请求头
2.  修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑
3.  新增Nostr适配器测试通用fixture
4.  更新NextcloudTalk签名相关测试与函数命名
This commit is contained in:
Kris 2026-05-12 14:52:12 +08:00
parent 1f78c44b03
commit fbe47b6393
8 changed files with 246 additions and 50 deletions

View File

@ -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:

View 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",
)

View File

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

View File

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

View File

@ -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"]},
},

View File

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

View File

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

View File

@ -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: