from __future__ import annotations import asyncio import pytest from cryptography.fernet import Fernet from yuxi.channels.auth.backoff import BackoffConfig, ExponentialBackoff from yuxi.channels.auth.health_monitor import AuthHealth, AuthHealthMonitor from yuxi.channels.auth.secret_manager import SecretManager, SecretSource from yuxi.channels.auth.security_policy import ( DmPolicy, GroupPolicy, SecurityPolicy, SecurityPolicyEngine, ) from yuxi.channels.auth.sensitive_filter import SensitiveDataFilter, redact_sensitive from yuxi.channels.auth.ssrf_guard import ( is_private_url, sanitize_transport_kwargs, validate_url, ) from yuxi.channels.auth.token_manager import TokenState, UnifiedTokenManager from yuxi.channels.auth.token_providers import ( OAuth2TokenProvider, StaticTokenProvider, ) class TestSecretManager: def test_encrypt_decrypt_with_key(self): key = Fernet.generate_key().decode() sm = SecretManager(fernet_key=key) original = "my-super-secret-token" encrypted = sm.encrypt_secret(original) assert encrypted != original decrypted = sm.decrypt_secret(encrypted) assert decrypted == original def test_encrypt_without_key(self): sm = SecretManager() original = "my-super-secret-token" encrypted = sm.encrypt_secret(original) assert encrypted == original def test_redact_short_value(self): result = SecretManager.redact("abc") assert result == "***" def test_redact_long_value(self): result = SecretManager.redact("abcdefghijklmnopqrstuvwxyz") assert result.startswith("abcd") assert result.endswith("wxyz") assert "****" in result def test_redact_none(self): result = SecretManager.redact(None) assert result == "***" def test_is_sensitive_key(self): assert SecretManager.is_sensitive_key("bot_token") assert SecretManager.is_sensitive_key("api_secret") assert SecretManager.is_sensitive_key("password") assert not SecretManager.is_sensitive_key("username") assert not SecretManager.is_sensitive_key("channel_name") def test_redact_config(self): config = { "bot_token": "secret123456789", "username": "mybot", "api_secret": "shhh", "nested": {"password": "nested_secret", "name": "test"}, } redacted = SecretManager.redact_config(config) assert redacted["bot_token"] != "secret123456789" assert "***" in redacted["bot_token"] assert redacted["username"] == "mybot" assert redacted["api_secret"] != "shhh" assert redacted["nested"]["password"] != "nested_secret" assert redacted["nested"]["name"] == "test" def test_is_encryption_available(self): key = Fernet.generate_key().decode() sm = SecretManager(fernet_key=key) assert sm.is_encryption_available sm2 = SecretManager() assert not sm2.is_encryption_available @pytest.mark.asyncio async def test_resolve_secret_from_config(self): sm = SecretManager() result = await sm.resolve_secret( "bot_token", sources=[SecretSource.CONFIG], config={"bot_token": "token123"}, ) assert result == "token123" @pytest.mark.asyncio async def test_resolve_secret_with_fallback(self): sm = SecretManager() result = await sm.resolve_secret( "missing_key", sources=[SecretSource.CONFIG], config={"bot_token": "token123"}, ) assert result is None class TestBackoff: def test_reset(self): backoff = ExponentialBackoff() backoff.mark_attempt() backoff.mark_attempt() assert backoff.attempt == 2 backoff.reset() assert backoff.attempt == 0 def test_next_delay_increases(self): config = BackoffConfig(base_seconds=1.0, multiplier=2.0, max_seconds=60.0) backoff = ExponentialBackoff(config) delays = [] for _ in range(5): delays.append(backoff.next_delay) backoff.mark_attempt() assert delays[0] == 0 assert delays[1] > 0 assert delays[1] < delays[3] def test_max_delay(self): config = BackoffConfig(base_seconds=1.0, multiplier=2.0, max_seconds=5.0) backoff = ExponentialBackoff(config) for _ in range(20): backoff.mark_attempt() delay = backoff.next_delay assert delay <= 5.0 * (1 + config.jitter_pct) class TestStaticTokenProvider: @pytest.mark.asyncio async def test_get_token(self): provider = StaticTokenProvider("static-token", scopes=["read", "write"]) state = await provider.get_token() assert state.token == "static-token" assert state.scopes == ["read", "write"] assert state.expires_at is None assert not state.is_expired @pytest.mark.asyncio async def test_validate_token(self): provider = StaticTokenProvider("valid-token") state = await provider.get_token() assert await provider.validate_token(state) class TestTokenManager: @pytest.mark.asyncio async def test_register_and_get_token(self): tm = UnifiedTokenManager() provider = StaticTokenProvider("test-token") tm.register_provider("test_channel", provider) state = await tm.get_token("test_channel") assert state.token == "test-token" @pytest.mark.asyncio async def test_unregistered_provider_raises(self): tm = UnifiedTokenManager() with pytest.raises(ValueError, match="No token provider"): await tm.get_token("nonexistent") @pytest.mark.asyncio async def test_invalidate_token(self): tm = UnifiedTokenManager() provider = StaticTokenProvider("test-token") tm.register_provider("test_channel", provider) await tm.get_token("test_channel") await tm.invalidate_token("test_channel") @pytest.mark.asyncio async def test_unregister_provider(self): tm = UnifiedTokenManager() provider = StaticTokenProvider("test-token") tm.register_provider("test_channel", provider) tm.unregister_provider("test_channel") with pytest.raises(ValueError, match="No token provider"): await tm.get_token("test_channel") class TestAuthHealthMonitor: def test_update_health(self): monitor = AuthHealthMonitor() health = AuthHealth(status="healthy", message="OK") monitor.update_health("ch1", health) retrieved = monitor.get_health("ch1") assert retrieved.status == "healthy" assert retrieved.is_healthy def test_update_unhealthy_triggers_callback(self): monitor = AuthHealthMonitor() monitor.update_health("ch1", AuthHealth(status="healthy")) notified = [] monitor.on_auth_failure(lambda cid, h: notified.append((cid, h.status))) monitor.update_health("ch1", AuthHealth(status="unhealthy")) def test_get_all_health(self): monitor = AuthHealthMonitor() monitor.update_health("ch1", AuthHealth(status="healthy")) monitor.update_health("ch2", AuthHealth(status="degraded", message="slow")) all_health = monitor.get_all_health() assert len(all_health) == 2 assert all_health["ch1"].is_healthy assert not all_health["ch2"].is_healthy class TestSecurityPolicy: def test_open_dm(self): policy = SecurityPolicy(dm_policy=DmPolicy.OPEN) assert policy.check_dm("any_user") def test_disabled_dm(self): policy = SecurityPolicy(dm_policy=DmPolicy.DISABLED) assert not policy.check_dm("any_user") def test_allowlist_dm(self): policy = SecurityPolicy(dm_policy=DmPolicy.ALLOWLIST, allowlist={"user1", "user2"}) assert policy.check_dm("user1") assert not policy.check_dm("user3") def test_open_group(self): policy = SecurityPolicy(group_policy=GroupPolicy.OPEN) assert policy.check_group() def test_disabled_group(self): policy = SecurityPolicy(group_policy=GroupPolicy.DISABLED) assert not policy.check_group() def test_from_config(self): config = { "dm_policy": "allowlist", "group_policy": "allowlist", "allowlist": ["alice", "bob"], } policy = SecurityPolicy.from_config(config) assert policy.dm_policy == DmPolicy.ALLOWLIST assert policy.group_policy == GroupPolicy.ALLOWLIST assert policy.is_allowlisted("alice") assert not policy.is_allowlisted("charlie") def test_add_remove_allowlist(self): policy = SecurityPolicy(dm_policy=DmPolicy.ALLOWLIST) policy.add_to_allowlist("new_user") assert policy.is_allowlisted("new_user") policy.remove_from_allowlist("new_user") assert not policy.is_allowlisted("new_user") def test_blocklist(self): policy = SecurityPolicy(blocklist={"spammer"}) assert policy.is_blocklisted("spammer") assert not policy.is_blocklisted("good_user") class TestSecurityPolicyEngine: def test_register_and_resolve(self): engine = SecurityPolicyEngine() policy = SecurityPolicy(dm_policy=DmPolicy.ALLOWLIST, allowlist={"user1"}) engine.register_policy("ch1", policy) assert engine.resolve_dm_policy("ch1") == DmPolicy.ALLOWLIST assert engine.check_allowlist("ch1", "user1") assert not engine.check_allowlist("ch1", "user2") def test_default_policy(self): engine = SecurityPolicyEngine() assert engine.resolve_dm_policy("unknown") == DmPolicy.OPEN class TestSensitiveDataFilter: def test_redact_dict(self): data = { "bot_token": "secret123", "channel_name": "mychannel", "api_secret": "shhh", } redacted = SensitiveDataFilter.redact_dict(data) assert redacted["bot_token"] != "secret123" assert redacted["channel_name"] == "mychannel" assert redacted["api_secret"] != "shhh" def test_redact_nested_dict(self): data = {"channels": {"telegram": {"bot_token": "tg_secret", "enabled": True}}} redacted = SensitiveDataFilter.redact_dict(data) assert redacted["channels"]["telegram"]["bot_token"] != "tg_secret" assert redacted["channels"]["telegram"]["enabled"] is True def test_is_sensitive(self): assert SensitiveDataFilter._is_sensitive("bot_token") assert SensitiveDataFilter._is_sensitive("APP_SECRET") assert not SensitiveDataFilter._is_sensitive("channel_id") def test_redact_for_logging(self): msg = "Starting bot with token=abc123def456ghi789 secret=shhh123" redacted = SensitiveDataFilter.redact_for_logging(msg) assert "abc123" not in redacted assert "shhh123" not in redacted assert "***" in redacted def test_redact_sensitive_helper(self): assert redact_sensitive("mysecret", key="bot_token") != "mysecret" assert redact_sensitive("myname", key="channel_name") == "myname" class TestSSRFGuard: def test_localhost_is_private(self): assert is_private_url("http://localhost:8080/api") assert is_private_url("http://127.0.0.1:8080/api") def test_private_ip_is_private(self): assert is_private_url("http://192.168.1.1/api") assert is_private_url("http://10.0.0.1/api") def test_public_ip_is_not_private(self): assert not is_private_url("http://8.8.8.8/api") assert not is_private_url("http://1.1.1.1/api") def test_sanitize_transport_kwargs(self): kwargs = {"agent": "custom", "cert": "fake.crt", "headers": {"X-Key": "val"}} cleaned = sanitize_transport_kwargs(kwargs) assert "agent" not in cleaned assert "cert" not in cleaned assert "headers" in cleaned def test_validate_url_with_private(self): with pytest.raises(ValueError, match="private"): validate_url("http://192.168.1.1/api") def test_validate_url_with_public(self): result = validate_url("http://8.8.8.8/api") assert result == "http://8.8.8.8/api" class TestTokenState: def test_not_expired_when_no_expiry(self): state = TokenState(token="test") assert not state.is_expired def test_expired_when_past_expiry(self): import time state = TokenState(token="test", expires_at=time.monotonic() - 3600) assert state.is_expired def test_not_expired_before_expiry(self): import time state = TokenState(token="test", expires_at=time.monotonic() + 3600) assert not state.is_expired def test_remaining_seconds(self): import time state = TokenState(token="test", expires_at=time.monotonic() + 600) remaining = state.remaining_seconds assert remaining is not None assert 0 < remaining < 600