364 lines
14 KiB
Python
364 lines
14 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import time
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.whatsapp.error_policy import ErrorPolicy, ErrorPolicyConfig
|
||
|
|
from yuxi.channels.adapters.whatsapp.group_gating import GroupGating
|
||
|
|
from yuxi.channels.adapters.whatsapp.pairing import PairRequest, PairingManager
|
||
|
|
from yuxi.channels.adapters.whatsapp.security import (
|
||
|
|
DmPolicy,
|
||
|
|
GroupPolicy,
|
||
|
|
WhatsAppSecurityConfig,
|
||
|
|
WhatsAppSecurityPolicy,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestErrorPolicy:
|
||
|
|
def test_enum_values(self):
|
||
|
|
assert ErrorPolicy.OFF == "off"
|
||
|
|
assert ErrorPolicy.FULL == "full"
|
||
|
|
assert ErrorPolicy.MINIMAL == "minimal"
|
||
|
|
|
||
|
|
|
||
|
|
class TestErrorPolicyConfig:
|
||
|
|
def test_from_config_default(self):
|
||
|
|
cfg = ErrorPolicyConfig.from_config({})
|
||
|
|
assert cfg.policy == ErrorPolicy.OFF
|
||
|
|
assert cfg.cooldown_ms == 5000
|
||
|
|
assert cfg.max_errors_per_window == 5
|
||
|
|
assert cfg.window_seconds == 60.0
|
||
|
|
|
||
|
|
def test_from_config_explicit(self):
|
||
|
|
cfg = ErrorPolicyConfig.from_config({
|
||
|
|
"errorPolicy": "full",
|
||
|
|
"errorCooldownMs": 3000,
|
||
|
|
"errorMaxPerWindow": 10,
|
||
|
|
"errorWindowSeconds": 120.0,
|
||
|
|
})
|
||
|
|
assert cfg.policy == ErrorPolicy.FULL
|
||
|
|
assert cfg.cooldown_ms == 3000
|
||
|
|
assert cfg.max_errors_per_window == 10
|
||
|
|
assert cfg.window_seconds == 120.0
|
||
|
|
|
||
|
|
def test_from_config_invalid_fallback(self):
|
||
|
|
cfg = ErrorPolicyConfig.from_config({"errorPolicy": "invalid"})
|
||
|
|
assert cfg.policy == ErrorPolicy.OFF
|
||
|
|
|
||
|
|
def test_should_notify_off_policy(self):
|
||
|
|
cfg = ErrorPolicyConfig(policy=ErrorPolicy.OFF)
|
||
|
|
assert cfg.should_notify("chat1", "test error") is False
|
||
|
|
|
||
|
|
def test_should_notify_full_policy(self):
|
||
|
|
cfg = ErrorPolicyConfig(policy=ErrorPolicy.FULL, window_seconds=60.0)
|
||
|
|
assert cfg.should_notify("chat1", "test error") is True
|
||
|
|
|
||
|
|
def test_should_notify_rate_limited(self):
|
||
|
|
cfg = ErrorPolicyConfig(policy=ErrorPolicy.FULL, max_errors_per_window=2, window_seconds=60.0)
|
||
|
|
cfg.should_notify("chat1", "error1")
|
||
|
|
cfg.should_notify("chat1", "error2")
|
||
|
|
assert cfg.should_notify("chat1", "error3") is False
|
||
|
|
|
||
|
|
def test_format_error_message_full(self):
|
||
|
|
cfg = ErrorPolicyConfig(policy=ErrorPolicy.FULL)
|
||
|
|
assert "Error:" in cfg.format_error_message("something broke")
|
||
|
|
|
||
|
|
def test_format_error_message_minimal(self):
|
||
|
|
cfg = ErrorPolicyConfig(policy=ErrorPolicy.MINIMAL)
|
||
|
|
msg = cfg.format_error_message("something broke")
|
||
|
|
assert "Error:" not in msg
|
||
|
|
assert "try again later" in msg
|
||
|
|
|
||
|
|
|
||
|
|
class TestDmPolicy:
|
||
|
|
def test_enum_values(self):
|
||
|
|
assert DmPolicy.PAIRING == "pairing"
|
||
|
|
assert DmPolicy.ALLOWLIST == "allowlist"
|
||
|
|
assert DmPolicy.OPEN == "open"
|
||
|
|
assert DmPolicy.DISABLED == "disabled"
|
||
|
|
|
||
|
|
|
||
|
|
class TestGroupPolicy:
|
||
|
|
def test_enum_values(self):
|
||
|
|
assert GroupPolicy.OPEN == "open"
|
||
|
|
assert GroupPolicy.ALLOWLIST == "allowlist"
|
||
|
|
assert GroupPolicy.DISABLED == "disabled"
|
||
|
|
|
||
|
|
|
||
|
|
class TestWhatsAppSecurityConfig:
|
||
|
|
def test_default_values(self):
|
||
|
|
cfg = WhatsAppSecurityConfig()
|
||
|
|
assert cfg.dm_policy == DmPolicy.PAIRING
|
||
|
|
assert cfg.group_policy == GroupPolicy.ALLOWLIST
|
||
|
|
assert cfg.allow_from == []
|
||
|
|
assert cfg.group_allow_from == []
|
||
|
|
|
||
|
|
|
||
|
|
class TestWhatsAppSecurityPolicy:
|
||
|
|
def test_default_resolution(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({})
|
||
|
|
assert policy.dm_policy == DmPolicy.OPEN
|
||
|
|
|
||
|
|
def test_dm_policy_pairing(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "pairing"})
|
||
|
|
assert policy.dm_policy == DmPolicy.PAIRING
|
||
|
|
|
||
|
|
def test_dm_policy_allowlist(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": ["+86138"]})
|
||
|
|
assert policy.dm_policy == DmPolicy.ALLOWLIST
|
||
|
|
|
||
|
|
def test_dm_policy_disabled(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "disabled"})
|
||
|
|
assert policy.dm_policy == DmPolicy.DISABLED
|
||
|
|
|
||
|
|
def test_group_policy_open(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupPolicy": "open"})
|
||
|
|
assert policy.group_policy == GroupPolicy.OPEN
|
||
|
|
|
||
|
|
def test_group_policy_allowlist_default(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({})
|
||
|
|
assert policy.group_policy == GroupPolicy.ALLOWLIST
|
||
|
|
|
||
|
|
def test_group_policy_disabled(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupPolicy": "disabled"})
|
||
|
|
assert policy.group_policy == GroupPolicy.DISABLED
|
||
|
|
|
||
|
|
def test_check_dm_access_open(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "open"})
|
||
|
|
ok, reason = policy.check_dm_access("8613800138000")
|
||
|
|
assert ok is True
|
||
|
|
assert reason is None
|
||
|
|
|
||
|
|
def test_check_dm_access_disabled(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "disabled"})
|
||
|
|
ok, reason = policy.check_dm_access("8613800138000")
|
||
|
|
assert ok is False
|
||
|
|
assert reason == "dm_disabled"
|
||
|
|
|
||
|
|
def test_check_dm_access_allowlist_match(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": ["8613800138000"]})
|
||
|
|
ok, reason = policy.check_dm_access("8613800138000")
|
||
|
|
assert ok is True
|
||
|
|
|
||
|
|
def test_check_dm_access_allowlist_no_match(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": ["8613800138000"]})
|
||
|
|
ok, reason = policy.check_dm_access("8613800000000")
|
||
|
|
assert ok is False
|
||
|
|
assert reason == "not_in_allowlist"
|
||
|
|
|
||
|
|
def test_check_dm_access_pairing(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "pairing"})
|
||
|
|
ok, reason = policy.check_dm_access("8613800138000")
|
||
|
|
assert ok is True
|
||
|
|
|
||
|
|
def test_check_group_access_open(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupPolicy": "open"})
|
||
|
|
ok, reason = policy.check_group_access("123456789@g.us")
|
||
|
|
assert ok is True
|
||
|
|
|
||
|
|
def test_check_group_access_disabled(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupPolicy": "disabled"})
|
||
|
|
ok, reason = policy.check_group_access("123456789@g.us")
|
||
|
|
assert ok is False
|
||
|
|
assert reason == "group_disabled"
|
||
|
|
|
||
|
|
def test_check_group_access_allowlist_match(self):
|
||
|
|
policy = WhatsAppSecurityPolicy(
|
||
|
|
{"groupPolicy": "allowlist", "groupAllowFrom": ["123456789@g.us"]}
|
||
|
|
)
|
||
|
|
ok, reason = policy.check_group_access("123456789@g.us")
|
||
|
|
assert ok is True
|
||
|
|
|
||
|
|
def test_check_group_access_allowlist_no_match(self):
|
||
|
|
policy = WhatsAppSecurityPolicy(
|
||
|
|
{"groupPolicy": "allowlist", "groupAllowFrom": ["123456789@g.us"]}
|
||
|
|
)
|
||
|
|
ok, reason = policy.check_group_access("999999999@g.us")
|
||
|
|
assert ok is False
|
||
|
|
|
||
|
|
def test_add_to_allow_list(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": []})
|
||
|
|
policy.add_to_allow_list("8613800138000")
|
||
|
|
assert "8613800138000" in policy.allow_list
|
||
|
|
|
||
|
|
def test_add_to_allow_list_duplicate(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": ["8613800138000"]})
|
||
|
|
policy.add_to_allow_list("8613800138000")
|
||
|
|
assert len(policy.allow_list) == 1
|
||
|
|
|
||
|
|
def test_remove_from_allow_list(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": ["8613800138000"]})
|
||
|
|
assert policy.remove_from_allow_list("8613800138000") is True
|
||
|
|
assert "8613800138000" not in policy.allow_list
|
||
|
|
|
||
|
|
def test_remove_from_allow_list_nonexistent(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": []})
|
||
|
|
assert policy.remove_from_allow_list("nonexistent") is False
|
||
|
|
|
||
|
|
def test_wildcard_allow_list(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"dmPolicy": "allowlist", "allowFrom": ["*"]})
|
||
|
|
ok, _ = policy.check_dm_access("any_number")
|
||
|
|
assert ok is True
|
||
|
|
|
||
|
|
def test_normalize_allow_list_strips_plus(self):
|
||
|
|
result = WhatsAppSecurityPolicy._normalize_allow_list(["+8613800138000"])
|
||
|
|
assert "8613800138000" in result
|
||
|
|
|
||
|
|
def test_match_allow_list_wildcard(self):
|
||
|
|
assert WhatsAppSecurityPolicy._match_allow_list("anything", ["*"]) is True
|
||
|
|
|
||
|
|
def test_match_allow_list_exact(self):
|
||
|
|
assert WhatsAppSecurityPolicy._match_allow_list("8613800138000", ["8613800138000"]) is True
|
||
|
|
|
||
|
|
def test_collect_warnings_group_open_no_list(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupPolicy": "open"})
|
||
|
|
warnings = policy.collect_warnings()
|
||
|
|
assert len(warnings) > 0
|
||
|
|
|
||
|
|
def test_apply_config_fixes(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupPolicy": "open"})
|
||
|
|
fixes = policy.apply_config_fixes()
|
||
|
|
assert len(fixes) > 0
|
||
|
|
|
||
|
|
def test_allow_list_property(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"allowFrom": ["8613800138000", "8613800138001"]})
|
||
|
|
assert len(policy.allow_list) == 2
|
||
|
|
|
||
|
|
def test_group_allow_list_property(self):
|
||
|
|
policy = WhatsAppSecurityPolicy({"groupAllowFrom": ["123456789@g.us"]})
|
||
|
|
assert len(policy.group_allow_list) == 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestPairRequest:
|
||
|
|
def test_create_pair_request(self):
|
||
|
|
req = PairRequest(phone_number="8613800138000", pairing_code="ABC123", expires_at=9999999999.0)
|
||
|
|
assert req.phone_number == "8613800138000"
|
||
|
|
assert req.pairing_code == "ABC123"
|
||
|
|
assert req.expires_at == 9999999999.0
|
||
|
|
|
||
|
|
def test_not_expired_future(self):
|
||
|
|
req = PairRequest(phone_number="86138", pairing_code="ABC", expires_at=9999999999.0)
|
||
|
|
assert req.is_expired is False
|
||
|
|
|
||
|
|
def test_not_expired_no_expiry(self):
|
||
|
|
req = PairRequest(phone_number="86138")
|
||
|
|
assert req.is_expired is False
|
||
|
|
|
||
|
|
def test_expired(self):
|
||
|
|
req = PairRequest(phone_number="86138", pairing_code="ABC", expires_at=0)
|
||
|
|
assert req.is_expired is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestPairingManager:
|
||
|
|
def test_create_pair_request(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
req = mgr.create_pair_request("8613800138000", "ABC123", timeout=300)
|
||
|
|
assert req.phone_number == "8613800138000"
|
||
|
|
assert req.pairing_code == "ABC123"
|
||
|
|
|
||
|
|
def test_confirm_pair_success(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC123")
|
||
|
|
assert mgr.confirm_pair("8613800138000") is True
|
||
|
|
|
||
|
|
def test_confirm_pair_nonexistent(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
assert mgr.confirm_pair("nonexistent") is False
|
||
|
|
|
||
|
|
def test_confirm_pair_expired(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC123", timeout=0)
|
||
|
|
time.sleep(0.01)
|
||
|
|
assert mgr.confirm_pair("8613800138000") is False
|
||
|
|
|
||
|
|
def test_is_paired_after_confirm(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC123")
|
||
|
|
mgr.confirm_pair("8613800138000")
|
||
|
|
assert mgr.is_paired("8613800138000") is True
|
||
|
|
|
||
|
|
def test_is_paired_false(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
assert mgr.is_paired("unknown") is False
|
||
|
|
|
||
|
|
def test_revoke_pair_pending(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC123")
|
||
|
|
assert mgr.revoke_pair("8613800138000") is True
|
||
|
|
assert mgr.confirm_pair("8613800138000") is False
|
||
|
|
|
||
|
|
def test_revoke_pair_paired(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC123")
|
||
|
|
mgr.confirm_pair("8613800138000")
|
||
|
|
assert mgr.is_paired("8613800138000") is True
|
||
|
|
assert mgr.revoke_pair("8613800138000") is True
|
||
|
|
assert mgr.is_paired("8613800138000") is False
|
||
|
|
|
||
|
|
def test_get_pending_phones(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC")
|
||
|
|
mgr.create_pair_request("8613800138001", "DEF")
|
||
|
|
phones = mgr.get_pending_phones()
|
||
|
|
assert len(phones) == 2
|
||
|
|
assert "8613800138000" in phones
|
||
|
|
|
||
|
|
def test_cleanup_expired(self):
|
||
|
|
mgr = PairingManager()
|
||
|
|
mgr.create_pair_request("8613800138000", "ABC", timeout=0)
|
||
|
|
mgr.create_pair_request("8613800138001", "DEF", timeout=300)
|
||
|
|
time.sleep(0.01)
|
||
|
|
cleaned = mgr.cleanup_expired()
|
||
|
|
assert cleaned == 1
|
||
|
|
phones = mgr.get_pending_phones()
|
||
|
|
assert "8613800138001" in phones
|
||
|
|
assert "8613800138000" not in phones
|
||
|
|
|
||
|
|
|
||
|
|
class TestGroupGating:
|
||
|
|
def test_default_activated(self):
|
||
|
|
gg = GroupGating()
|
||
|
|
assert gg.is_activated("123456789@g.us") is True
|
||
|
|
|
||
|
|
def test_require_mention_default_false(self):
|
||
|
|
gg = GroupGating()
|
||
|
|
assert gg.require_mention("123456789@g.us") is False
|
||
|
|
|
||
|
|
def test_require_mention_configured(self):
|
||
|
|
gg = GroupGating({"groups": {"123456789": {"requireMention": True}}})
|
||
|
|
assert gg.require_mention("123456789@g.us") is True
|
||
|
|
|
||
|
|
def test_require_mention_by_full_jid(self):
|
||
|
|
gg = GroupGating({"groups": {"123456789@g.us": {"requireMention": True}}})
|
||
|
|
assert gg.require_mention("123456789@g.us") is True
|
||
|
|
|
||
|
|
def test_is_activated_configured_false(self):
|
||
|
|
gg = GroupGating({"groups": {"123456789": {"activated": False}}})
|
||
|
|
assert gg.is_activated("123456789@g.us") is False
|
||
|
|
|
||
|
|
def test_activate(self):
|
||
|
|
gg = GroupGating()
|
||
|
|
gg.deactivate("123456789@g.us")
|
||
|
|
assert gg.is_activated("123456789@g.us") is False
|
||
|
|
gg.activate("123456789@g.us")
|
||
|
|
assert gg.is_activated("123456789@g.us") is True
|
||
|
|
|
||
|
|
def test_deactivate(self):
|
||
|
|
gg = GroupGating()
|
||
|
|
gg.deactivate("123456789@g.us")
|
||
|
|
assert gg.is_activated("123456789@g.us") is False
|
||
|
|
|
||
|
|
def test_empty_config(self):
|
||
|
|
gg = GroupGating({})
|
||
|
|
assert gg.is_activated("any@g.us") is True
|
||
|
|
|
||
|
|
def test_invalid_groups_config(self):
|
||
|
|
gg = GroupGating({"groups": "not_a_dict"})
|
||
|
|
assert gg.is_activated("test@g.us") is True
|
||
|
|
|
||
|
|
def test_non_dict_group_entry(self):
|
||
|
|
gg = GroupGating({"groups": {"g1": "not_a_dict"}})
|
||
|
|
assert gg.is_activated("g1@g.us") is True
|