diff --git a/backend/server/routers/webhook_router.py b/backend/server/routers/webhook_router.py index 0f9175f8..3070d00a 100644 --- a/backend/server/routers/webhook_router.py +++ b/backend/server/routers/webhook_router.py @@ -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: diff --git a/backend/test/unit/channels/conftest.py b/backend/test/unit/channels/conftest.py new file mode 100644 index 00000000..0ba3ce49 --- /dev/null +++ b/backend/test/unit/channels/conftest.py @@ -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", + ) \ No newline at end of file diff --git a/backend/test/unit/channels/test_channels_matrix.py b/backend/test/unit/channels/test_channels_matrix.py index 1bdc529c..7ec6372f 100644 --- a/backend/test/unit/channels/test_channels_matrix.py +++ b/backend/test/unit/channels/test_channels_matrix.py @@ -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" diff --git a/backend/test/unit/channels/test_channels_slack_adapter.py b/backend/test/unit/channels/test_channels_slack_adapter.py index 6df2e4ee..cef7819c 100644 --- a/backend/test/unit/channels/test_channels_slack_adapter.py +++ b/backend/test/unit/channels/test_channels_slack_adapter.py @@ -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" diff --git a/backend/test/unit/channels/test_discord_adapter_p0.py b/backend/test/unit/channels/test_discord_adapter_p0.py index c29f19d3..d2533e6d 100644 --- a/backend/test/unit/channels/test_discord_adapter_p0.py +++ b/backend/test/unit/channels/test_discord_adapter_p0.py @@ -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"]}, }, diff --git a/backend/test/unit/channels/test_nextcloudtalk_adapter.py b/backend/test/unit/channels/test_nextcloudtalk_adapter.py index aa48fbee..3a8083ae 100644 --- a/backend/test/unit/channels/test_nextcloudtalk_adapter.py +++ b/backend/test/unit/channels/test_nextcloudtalk_adapter.py @@ -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 diff --git a/backend/test/unit/channels/test_synologychat_adapter.py b/backend/test/unit/channels/test_synologychat_adapter.py index 48c5e09f..ee803bc2 100644 --- a/backend/test/unit/channels/test_synologychat_adapter.py +++ b/backend/test/unit/channels/test_synologychat_adapter.py @@ -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"] diff --git a/backend/test/unit/channels/test_urbit_adapter.py b/backend/test/unit/channels/test_urbit_adapter.py index ed5a692c..d8a7fae8 100644 --- a/backend/test/unit/channels/test_urbit_adapter.py +++ b/backend/test/unit/channels/test_urbit_adapter.py @@ -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: