新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
236 lines
7.5 KiB
Python
236 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import random
|
|
import string
|
|
import time
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.nostr.adapter import NostrAdapter
|
|
from yuxi.channels.adapters.nostr.guard import GuardPolicy, NostrGuard, SeenTracker, RateLimiter
|
|
from yuxi.channels.adapters.nostr.crypto import normalize_pubkey
|
|
|
|
|
|
def random_hex(length: int = 64) -> str:
|
|
return "".join(random.choices("0123456789abcdef", k=length))
|
|
|
|
|
|
def random_event_id() -> str:
|
|
return random_hex(64)
|
|
|
|
|
|
def random_pubkey() -> str:
|
|
return random_hex(64)
|
|
|
|
|
|
def make_fuzz_event(overrides: dict[str, Any] | None = None) -> dict:
|
|
base = {
|
|
"id": random_event_id(),
|
|
"kind": random.choice([1, 4, 5, 7]),
|
|
"content": "".join(random.choices(string.printable, k=random.randint(0, 5000))),
|
|
"pubkey": random_pubkey(),
|
|
"tags": [],
|
|
"created_at": int(time.time()) + random.randint(-3600, 3600),
|
|
}
|
|
if overrides:
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
class TestNormalizeInboundFuzz:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return NostrAdapter(config={"private_key": None, "relays": [], "dm_policy": "open"})
|
|
|
|
def test_normalize_handles_random_events(self, adapter):
|
|
for i in range(100):
|
|
event = make_fuzz_event()
|
|
try:
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
assert msg.content is not None
|
|
except Exception as e:
|
|
pytest.fail(f"normalize_inbound crashed on iteration {i}: {e}\nEvent: {event}")
|
|
|
|
def test_normalize_handles_empty_content(self, adapter):
|
|
event = make_fuzz_event({"content": ""})
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
|
|
def test_normalize_handles_missing_fields(self, adapter):
|
|
event: dict = {}
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
|
|
def test_normalize_handles_none_tags(self, adapter):
|
|
event = make_fuzz_event({"tags": None})
|
|
try:
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
except Exception:
|
|
pass
|
|
|
|
def test_normalize_handles_malformed_tags(self, adapter):
|
|
event = make_fuzz_event({"tags": ["not_a_list", 123, None, []]})
|
|
try:
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
except Exception:
|
|
pass
|
|
|
|
def test_normalize_handles_large_content(self, adapter):
|
|
event = make_fuzz_event({"content": "x" * 1_000_000})
|
|
try:
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
except Exception:
|
|
pass
|
|
|
|
def test_normalize_handles_all_kinds(self, adapter):
|
|
for kind in [0, 1, 2, 3, 4, 5, 6, 7, 8, 1059, 10000, 30000, 99999]:
|
|
event = make_fuzz_event({"kind": kind})
|
|
try:
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
except Exception:
|
|
pass
|
|
|
|
def test_normalize_handles_unicode_content(self, adapter):
|
|
unicode_chars = "日本語 한국어 中文 العربية עברית 🌍✨🔥"
|
|
event = make_fuzz_event({"content": unicode_chars})
|
|
msg = adapter.normalize_inbound(event)
|
|
assert unicode_chars in msg.content
|
|
|
|
def test_normalize_handles_binary_like_content(self, adapter):
|
|
event = make_fuzz_event({"content": "\x00\x01\x02\xff\xfe"})
|
|
try:
|
|
msg = adapter.normalize_inbound(event)
|
|
assert msg is not None
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class TestGuardFuzz:
|
|
@pytest.fixture
|
|
def guard(self):
|
|
return NostrGuard(own_pubkey=random_hex())
|
|
|
|
def test_guard_handles_random_events(self, guard):
|
|
for i in range(200):
|
|
event = make_fuzz_event()
|
|
try:
|
|
result = guard.check(event)
|
|
assert result is None or isinstance(result, str)
|
|
except Exception as e:
|
|
pytest.fail(f"guard.check crashed on iteration {i}: {e}")
|
|
|
|
def test_guard_handles_empty_event(self, guard):
|
|
result = guard.check({})
|
|
assert result == "missing event id"
|
|
|
|
def test_guard_handles_missing_pubkey(self, guard):
|
|
event = make_fuzz_event()
|
|
event.pop("pubkey", None)
|
|
try:
|
|
guard.check(event)
|
|
except Exception:
|
|
pass
|
|
|
|
def test_guard_handles_missing_created_at(self, guard):
|
|
event = make_fuzz_event()
|
|
event.pop("created_at", None)
|
|
try:
|
|
guard.check(event)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class TestRateLimiterFuzz:
|
|
def test_rate_limiter_handles_many_senders(self):
|
|
limiter = RateLimiter(window_ms=10_000, max_per_sender=20, max_global=1000)
|
|
senders = set()
|
|
for i in range(5000):
|
|
sender = random_pubkey()
|
|
senders.add(sender)
|
|
try:
|
|
limiter.check_and_record(sender)
|
|
except Exception as e:
|
|
pytest.fail(f"RateLimiter crashed on iteration {i}: {e}")
|
|
|
|
assert len(senders) > 0
|
|
|
|
def test_rate_limiter_burst(self):
|
|
limiter = RateLimiter(window_ms=10_000, max_per_sender=5, max_global=200)
|
|
sender = random_hex()
|
|
accepted = 0
|
|
rejected = 0
|
|
for _ in range(100):
|
|
if limiter.check_and_record(sender):
|
|
accepted += 1
|
|
else:
|
|
rejected += 1
|
|
assert rejected > 0
|
|
|
|
|
|
class TestSeenTrackerFuzz:
|
|
def test_seen_tracker_large_volume(self):
|
|
tracker = SeenTracker(max_size=10_000)
|
|
for i in range(15_000):
|
|
event_id = random_hex(20)
|
|
tracker.mark_seen(event_id)
|
|
assert len(tracker) <= 10_000
|
|
|
|
def test_seen_tracker_ttl_eviction(self):
|
|
tracker = SeenTracker(max_size=100_000, ttl_sec=0)
|
|
for _ in range(500):
|
|
event_id = random_hex(20)
|
|
tracker.mark_seen(event_id)
|
|
assert len(tracker) == 0
|
|
|
|
|
|
class TestNormalizePubkeyFuzz:
|
|
def test_normalize_pubkey_handles_various_inputs(self):
|
|
test_inputs = [
|
|
"",
|
|
None,
|
|
" ",
|
|
"npub1invalid",
|
|
"nprofile1bad",
|
|
"nostr:",
|
|
random_hex(64),
|
|
random_hex(32),
|
|
random_hex(128),
|
|
"not_a_pubkey",
|
|
"https://example.com",
|
|
"a" * 1000,
|
|
]
|
|
for inp in test_inputs:
|
|
try:
|
|
result = normalize_pubkey(inp)
|
|
assert isinstance(result, str)
|
|
except Exception as e:
|
|
pytest.fail(f"normalize_pubkey crashed on input '{inp}': {e}")
|
|
|
|
|
|
class TestMarkdownTableFuzz:
|
|
def test_convert_tables_handles_various_formats(self):
|
|
from yuxi.channels.adapters.nostr.send import NostrSender
|
|
|
|
inputs = [
|
|
"| A | B |\n| --- | --- |\n| 1 | 2 |",
|
|
"no table here",
|
|
"| single |\n| --- |\n| val |",
|
|
"| a | b | c | d | e | f |\n| --- | --- | --- | --- | --- | --- |\n| 1 | 2 | 3 | 4 | 5 | 6 |",
|
|
"| :--- | :---: | ---: |\n| a | b | c |",
|
|
"| Header ||\n| --- | --- |\n| val |",
|
|
"",
|
|
"x" * 10000,
|
|
]
|
|
for inp in inputs:
|
|
try:
|
|
result = NostrSender.convert_markdown_tables(inp)
|
|
assert isinstance(result, str)
|
|
except Exception as e:
|
|
pytest.fail(f"convert_markdown_tables crashed on input: {e}") |