301 lines
12 KiB
Python
301 lines
12 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.nostr.config import (
|
||
|
|
NostrConfig,
|
||
|
|
NostrConfigError,
|
||
|
|
GuardPolicyConfig,
|
||
|
|
RateLimitConfig,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrConfigValidation:
|
||
|
|
def test_invalid_dm_policy_raises(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="dm_policy"):
|
||
|
|
NostrConfig(dm_policy="invalid_policy")
|
||
|
|
|
||
|
|
def test_invalid_streaming_mode_raises(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="streaming_mode"):
|
||
|
|
NostrConfig(streaming_mode="realtime")
|
||
|
|
|
||
|
|
def test_invalid_markdown_table_mode_raises(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="markdown_table_mode"):
|
||
|
|
NostrConfig(markdown_table_mode="invalid")
|
||
|
|
|
||
|
|
def test_whitelist_aliased_to_allowlist(self):
|
||
|
|
cfg = NostrConfig(dm_policy="whitelist")
|
||
|
|
assert cfg.dm_policy == "allowlist"
|
||
|
|
|
||
|
|
def test_reconnect_interval_too_small_raises(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="reconnect_interval_sec"):
|
||
|
|
NostrConfig(reconnect_interval_sec=0)
|
||
|
|
|
||
|
|
def test_relay_timeout_too_small_raises(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="relay_timeout_sec"):
|
||
|
|
NostrConfig(relay_timeout_sec=0)
|
||
|
|
|
||
|
|
def test_degraded_threshold_out_of_range_low(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="relay_degraded_threshold"):
|
||
|
|
NostrConfig(relay_degraded_threshold=0)
|
||
|
|
|
||
|
|
def test_degraded_threshold_out_of_range_high(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="relay_degraded_threshold"):
|
||
|
|
NostrConfig(relay_degraded_threshold=1.5)
|
||
|
|
|
||
|
|
def test_degraded_threshold_valid(self):
|
||
|
|
cfg = NostrConfig(relay_degraded_threshold=0.8)
|
||
|
|
assert cfg.relay_degraded_threshold == 0.8
|
||
|
|
|
||
|
|
def test_relay_url_with_invalid_prefix_raises(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="ws:// 或 wss://"):
|
||
|
|
NostrConfig(relays=["http://bad.relay"])
|
||
|
|
|
||
|
|
def test_relay_url_ws_rejected_when_require_tls(self):
|
||
|
|
with pytest.raises(NostrConfigError, match="明文 ws://"):
|
||
|
|
NostrConfig(relays=["ws://plain.relay"], require_tls=True)
|
||
|
|
|
||
|
|
def test_relay_url_ws_allowed_when_require_tls_false(self):
|
||
|
|
cfg = NostrConfig(relays=["ws://plain.relay"], require_tls=False)
|
||
|
|
assert "ws://plain.relay" in cfg.relays
|
||
|
|
|
||
|
|
def test_all_valid_dm_policies(self):
|
||
|
|
for policy in NostrConfig.VALID_DM_POLICIES:
|
||
|
|
if policy == "whitelist":
|
||
|
|
continue
|
||
|
|
cfg = NostrConfig(dm_policy=policy)
|
||
|
|
assert cfg.dm_policy == policy
|
||
|
|
|
||
|
|
def test_all_streaming_modes(self):
|
||
|
|
for mode in NostrConfig.VALID_STREAMING_MODES:
|
||
|
|
cfg = NostrConfig(streaming_mode=mode)
|
||
|
|
assert cfg.streaming_mode == mode
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrConfigFromEnv:
|
||
|
|
def test_from_env_defaults(self):
|
||
|
|
with patch.dict(os.environ, {}, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.private_key == ""
|
||
|
|
assert len(cfg.relays) == 4
|
||
|
|
assert cfg.dm_policy == "pairing"
|
||
|
|
assert cfg.nip17_enabled is True
|
||
|
|
|
||
|
|
def test_from_env_custom_values(self):
|
||
|
|
env_vars = {
|
||
|
|
"NOSTR_PRIVATE_KEY": "nsec1testkey123",
|
||
|
|
"NOSTR_RELAYS": "wss://custom.relay,wss://custom2.relay",
|
||
|
|
"NOSTR_DM_POLICY": "open",
|
||
|
|
"NOSTR_STREAMING_MODE": "progress",
|
||
|
|
"NOSTR_RECONNECT_INTERVAL_SEC": "10",
|
||
|
|
"NOSTR_RELAY_TIMEOUT_SEC": "60",
|
||
|
|
"NOSTR_RELAY_DEGRADED_THRESHOLD": "0.7",
|
||
|
|
"NOSTR_NIP17_ENABLED": "false",
|
||
|
|
"NOSTR_NIP42_AUTH_ENABLED": "true",
|
||
|
|
"NOSTR_BACKFILL_WINDOW_SEC": "300",
|
||
|
|
"NOSTR_ENABLED": "false",
|
||
|
|
"NOSTR_NAME": "test_bot",
|
||
|
|
"NOSTR_SEND_CACHE_SIZE": "200",
|
||
|
|
"NOSTR_MESSAGE_ORDERING": "true",
|
||
|
|
"NOSTR_MESSAGE_ORDERING_WINDOW_MS": "1000",
|
||
|
|
"NOSTR_MULTI_ACCOUNT_ENABLED": "true",
|
||
|
|
"NOSTR_REQUIRE_TLS": "false",
|
||
|
|
}
|
||
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.private_key == "nsec1testkey123"
|
||
|
|
assert len(cfg.relays) == 2
|
||
|
|
assert cfg.dm_policy == "open"
|
||
|
|
assert cfg.streaming_mode == "progress"
|
||
|
|
assert cfg.reconnect_interval_sec == 10
|
||
|
|
assert cfg.relay_timeout_sec == 60
|
||
|
|
assert cfg.relay_degraded_threshold == 0.7
|
||
|
|
assert cfg.nip17_enabled is False
|
||
|
|
assert cfg.nip42_auth_enabled is True
|
||
|
|
assert cfg.backfill_window_sec == 300
|
||
|
|
assert cfg.enabled is False
|
||
|
|
assert cfg.name == "test_bot"
|
||
|
|
assert cfg.send_message_cache_size == 200
|
||
|
|
assert cfg.message_ordering is True
|
||
|
|
assert cfg.message_ordering_window_ms == 1000
|
||
|
|
assert cfg.multi_account_enabled is True
|
||
|
|
assert cfg.require_tls is False
|
||
|
|
|
||
|
|
def test_from_env_bool_variants(self):
|
||
|
|
variants = [
|
||
|
|
("1", True), ("true", True), ("TRUE", True), ("yes", True), ("on", True),
|
||
|
|
("0", False), ("false", False), ("no", False), ("off", False),
|
||
|
|
]
|
||
|
|
for val, expected in variants:
|
||
|
|
env_vars = {"NOSTR_NIP17_ENABLED": val}
|
||
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.nip17_enabled == expected, f"Failed for value {val}"
|
||
|
|
|
||
|
|
def test_from_env_bool_missing_defaults_to_false(self):
|
||
|
|
with patch.dict(os.environ, {}, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.nip42_auth_enabled is False
|
||
|
|
assert cfg.message_ordering is False
|
||
|
|
|
||
|
|
def test_from_env_int_invalid_falls_back_to_default(self):
|
||
|
|
env_vars = {"NOSTR_RECONNECT_INTERVAL_SEC": "not_a_number"}
|
||
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.reconnect_interval_sec == 5
|
||
|
|
|
||
|
|
def test_from_env_float_invalid_falls_back_to_default(self):
|
||
|
|
env_vars = {"NOSTR_RELAY_DEGRADED_THRESHOLD": "abc"}
|
||
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.relay_degraded_threshold == 0.5
|
||
|
|
|
||
|
|
def test_from_env_list_splits_commas(self):
|
||
|
|
env_vars = {"NOSTR_ALLOW_FROM": " pub_a , pub_b , pub_c "}
|
||
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.allow_from == ["pub_a", "pub_b", "pub_c"]
|
||
|
|
|
||
|
|
def test_from_env_list_empty_returns_none(self):
|
||
|
|
with patch.dict(os.environ, {"NOSTR_ALLOW_FROM": ""}, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.allow_from == []
|
||
|
|
|
||
|
|
def test_from_env_guard_policy(self):
|
||
|
|
env_vars = {
|
||
|
|
"NOSTR_GUARD_ALLOWED_KINDS": "1,4,7",
|
||
|
|
"NOSTR_GUARD_MAX_CIPHERTEXT_BYTES": "30000",
|
||
|
|
"NOSTR_GUARD_MAX_FUTURE_SKEW_SEC": "60",
|
||
|
|
"NOSTR_RATE_LIMIT_WINDOW_MS": "5000",
|
||
|
|
"NOSTR_RATE_LIMIT_MAX_PER_SENDER": "15",
|
||
|
|
"NOSTR_RATE_LIMIT_MAX_GLOBAL": "300",
|
||
|
|
}
|
||
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
||
|
|
cfg = NostrConfig.from_env()
|
||
|
|
assert cfg.guard_policy.allowed_kinds == ["1", "4", "7"]
|
||
|
|
assert cfg.guard_policy.max_ciphertext_bytes == 30000
|
||
|
|
assert cfg.guard_policy.max_future_skew_sec == 60
|
||
|
|
assert cfg.guard_policy.rate_limit.window_ms == 5000
|
||
|
|
assert cfg.guard_policy.rate_limit.max_per_sender_per_window == 15
|
||
|
|
assert cfg.guard_policy.rate_limit.max_global_per_window == 300
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrConfigAccountManagement:
|
||
|
|
def test_get_account_configs_empty_returns_default(self):
|
||
|
|
cfg = NostrConfig()
|
||
|
|
result = cfg.get_account_configs({})
|
||
|
|
assert "default" in result
|
||
|
|
assert len(result) == 1
|
||
|
|
|
||
|
|
def test_get_account_configs_with_accounts(self):
|
||
|
|
raw = {
|
||
|
|
"accounts": {
|
||
|
|
"account1": {"private_key": "nsec1aaa", "enabled": True},
|
||
|
|
"account2": {"private_key": "nsec1bbb", "enabled": True},
|
||
|
|
"disabled_account": {"private_key": "nsec1ccc", "enabled": False},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cfg = NostrConfig()
|
||
|
|
result = cfg.get_account_configs(raw)
|
||
|
|
assert "account1" in result
|
||
|
|
assert "account2" in result
|
||
|
|
assert "disabled_account" not in result
|
||
|
|
assert len(result) == 2
|
||
|
|
|
||
|
|
def test_get_account_configs_all_disabled_returns_default(self):
|
||
|
|
raw = {
|
||
|
|
"accounts": {
|
||
|
|
"disabled1": {"enabled": False},
|
||
|
|
"disabled2": {"enabled": False},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cfg = NostrConfig()
|
||
|
|
result = cfg.get_account_configs(raw)
|
||
|
|
assert "default" in result
|
||
|
|
assert len(result) == 1
|
||
|
|
|
||
|
|
def test_list_account_ids_empty(self):
|
||
|
|
cfg = NostrConfig()
|
||
|
|
assert cfg.list_account_ids({}) == ["default"]
|
||
|
|
|
||
|
|
def test_list_account_ids_with_accounts(self):
|
||
|
|
raw = {
|
||
|
|
"accounts": {
|
||
|
|
"account_a": {"enabled": True},
|
||
|
|
"account_b": {"enabled": True},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cfg = NostrConfig()
|
||
|
|
result = cfg.list_account_ids(raw)
|
||
|
|
assert result == ["account_a", "account_b"]
|
||
|
|
|
||
|
|
def test_list_account_ids_skips_disabled(self):
|
||
|
|
raw = {
|
||
|
|
"accounts": {
|
||
|
|
"enabled_acc": {"enabled": True},
|
||
|
|
"disabled_acc": {"enabled": False},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cfg = NostrConfig()
|
||
|
|
result = cfg.list_account_ids(raw)
|
||
|
|
assert result == ["enabled_acc"]
|
||
|
|
|
||
|
|
def test_resolve_default_account_id(self):
|
||
|
|
cfg = NostrConfig()
|
||
|
|
assert cfg.resolve_default_account_id() == "default"
|
||
|
|
|
||
|
|
def test_from_dict_with_flat_rate_limit_legacy(self):
|
||
|
|
cfg = NostrConfig.from_dict({
|
||
|
|
"accounts": {
|
||
|
|
"default": {
|
||
|
|
"guard_policy": {
|
||
|
|
"rate_limit_window_ms": 8000,
|
||
|
|
"rate_limit_max_per_sender_per_window": 25,
|
||
|
|
"rate_limit_max_global_per_window": 500,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
assert cfg.guard_policy.rate_limit.window_ms == 8000
|
||
|
|
assert cfg.guard_policy.rate_limit.max_per_sender_per_window == 25
|
||
|
|
assert cfg.guard_policy.rate_limit.max_global_per_window == 500
|
||
|
|
|
||
|
|
def test_guard_policy_config_defaults(self):
|
||
|
|
gpc = GuardPolicyConfig()
|
||
|
|
assert gpc.allowed_kinds == [1, 4, 5, 7, 1059]
|
||
|
|
assert gpc.max_ciphertext_bytes == 50_000
|
||
|
|
assert gpc.max_plaintext_bytes == 10_000
|
||
|
|
assert gpc.max_future_skew_sec == 30
|
||
|
|
|
||
|
|
def test_rate_limit_config_defaults(self):
|
||
|
|
rlc = RateLimitConfig()
|
||
|
|
assert rlc.window_ms == 10_000
|
||
|
|
assert rlc.max_per_sender_per_window == 20
|
||
|
|
assert rlc.max_global_per_window == 200
|
||
|
|
|
||
|
|
|
||
|
|
class TestNostrConfigFromDictEdgeCases:
|
||
|
|
def test_from_dict_with_zero_backfill(self):
|
||
|
|
cfg = NostrConfig.from_dict({
|
||
|
|
"accounts": {"default": {"backfill_window_sec": 0}}
|
||
|
|
})
|
||
|
|
assert cfg.backfill_window_sec == 0
|
||
|
|
|
||
|
|
def test_from_dict_with_allow_from(self):
|
||
|
|
cfg = NostrConfig.from_dict({
|
||
|
|
"accounts": {"default": {"allow_from": ["pub_a", "pub_b"]}}
|
||
|
|
})
|
||
|
|
assert cfg.allow_from == ["pub_a", "pub_b"]
|
||
|
|
|
||
|
|
def test_from_dict_with_markdown_table_convert(self):
|
||
|
|
cfg = NostrConfig.from_dict({
|
||
|
|
"accounts": {"default": {"markdown_table_mode": "convert"}}
|
||
|
|
})
|
||
|
|
assert cfg.markdown_table_mode == "convert"
|
||
|
|
|
||
|
|
def test_from_dict_degraded_threshold_default(self):
|
||
|
|
cfg = NostrConfig.from_dict({})
|
||
|
|
assert cfg.relay_degraded_threshold == 0.5
|