1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
338 lines
12 KiB
Python
338 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.whatsapp.credential_queue import CredentialQueue
|
|
from yuxi.channels.adapters.whatsapp.login import login_with_qr
|
|
from yuxi.channels.adapters.whatsapp.logout_security import (
|
|
PathOwnership,
|
|
_classify_path,
|
|
_is_safe_path,
|
|
perform_logout_cleanup,
|
|
validate_credential_freshness,
|
|
)
|
|
from yuxi.channels.adapters.whatsapp.probe import probe_bridge, wait_for_connection
|
|
from yuxi.channels.models import HealthStatus
|
|
|
|
|
|
class TestPathOwnership:
|
|
def test_enum_values(self):
|
|
assert PathOwnership.OWNED == "owned"
|
|
assert PathOwnership.UNSAFE_OWNED == "unsafe_owned"
|
|
assert PathOwnership.EXTERNAL == "external"
|
|
|
|
|
|
class TestClassifyPath:
|
|
def test_owned(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
target = auth_dir / "creds.json"
|
|
target.write_text("{}")
|
|
assert _classify_path(auth_dir, target) == PathOwnership.OWNED
|
|
|
|
def test_external(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
external = tmp_path / "external" / "creds.json"
|
|
external.parent.mkdir()
|
|
external.write_text("{}")
|
|
assert _classify_path(auth_dir, external) == PathOwnership.EXTERNAL
|
|
|
|
def test_is_safe_path_owned(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
target = auth_dir / "file.txt"
|
|
target.write_text("test")
|
|
assert _is_safe_path(auth_dir, target) is True
|
|
|
|
def test_is_safe_path_external(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
external = tmp_path / "other" / "file.txt"
|
|
external.parent.mkdir()
|
|
external.write_text("test")
|
|
assert _is_safe_path(auth_dir, external) is False
|
|
|
|
def test_external_symlink(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
external = tmp_path / "external" / "creds.json"
|
|
external.parent.mkdir()
|
|
external.write_text("{}")
|
|
symlink = auth_dir / "creds.json"
|
|
try:
|
|
symlink.symlink_to(external)
|
|
result = _classify_path(auth_dir, symlink)
|
|
assert result in (PathOwnership.EXTERNAL, PathOwnership.UNSAFE_OWNED)
|
|
except OSError:
|
|
pytest.skip("symlink not supported on this platform")
|
|
|
|
|
|
class TestPerformLogoutCleanup:
|
|
def test_cleanup_owned_files(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
for filename in ["creds.json", "creds.json.bak", "app-state-sync-key.json"]:
|
|
(auth_dir / filename).write_text("{}")
|
|
result = perform_logout_cleanup(auth_dir)
|
|
assert result["owned"] > 0
|
|
|
|
def test_external_auth_dir(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
external = tmp_path / "other"
|
|
external.mkdir()
|
|
auth_dir_link = external / "link"
|
|
auth_dir_link.symlink_to(auth_dir)
|
|
result = perform_logout_cleanup(auth_dir_link)
|
|
assert result["unsafe_owned"] > 0 or result["external"] > 0
|
|
|
|
def test_missing_files_no_error(self, tmp_path):
|
|
auth_dir = tmp_path / "empty_auth"
|
|
auth_dir.mkdir()
|
|
result = perform_logout_cleanup(auth_dir)
|
|
assert result["owned"] == 0
|
|
|
|
def test_cleanup_returns_dict(self, tmp_path):
|
|
auth_dir = tmp_path / "auth"
|
|
auth_dir.mkdir()
|
|
(auth_dir / "creds.json").write_text("{}")
|
|
result = perform_logout_cleanup(auth_dir)
|
|
assert "owned" in result
|
|
assert "unsafe_owned" in result
|
|
assert "external" in result
|
|
|
|
|
|
class TestValidateCredentialFreshness:
|
|
def test_valid_fresh_credentials(self, tmp_path):
|
|
auth_dir = tmp_path / "fresh_auth"
|
|
auth_dir.mkdir()
|
|
(auth_dir / "creds.json").write_text("{}")
|
|
assert validate_credential_freshness(auth_dir, max_age_hours=168) is True
|
|
|
|
def test_expired_credentials(self, tmp_path):
|
|
auth_dir = tmp_path / "old_auth"
|
|
auth_dir.mkdir()
|
|
creds = auth_dir / "creds.json"
|
|
creds.write_text("{}")
|
|
old_time = time.time() - 200 * 3600
|
|
os.utime(str(creds), (old_time, old_time))
|
|
assert validate_credential_freshness(auth_dir, max_age_hours=168) is False
|
|
|
|
def test_missing_credentials(self, tmp_path):
|
|
auth_dir = tmp_path / "empty"
|
|
auth_dir.mkdir()
|
|
assert validate_credential_freshness(auth_dir) is False
|
|
|
|
def test_custom_max_age(self, tmp_path):
|
|
auth_dir = tmp_path / "custom_auth"
|
|
auth_dir.mkdir()
|
|
(auth_dir / "creds.json").write_text("{}")
|
|
assert validate_credential_freshness(auth_dir, max_age_hours=1) is True
|
|
|
|
def test_creds_file_error(self, tmp_path):
|
|
auth_dir = tmp_path / "error_auth"
|
|
auth_dir.mkdir()
|
|
creds = auth_dir / "creds.json"
|
|
creds.write_text("{invalid json}")
|
|
assert validate_credential_freshness(auth_dir) is True
|
|
|
|
|
|
class TestLoginWithQr:
|
|
@pytest.mark.asyncio
|
|
async def test_qr_generation_fails(self):
|
|
bridge = MagicMock()
|
|
bridge.get_qr = AsyncMock(side_effect=Exception("connection failed"))
|
|
result = await login_with_qr(bridge)
|
|
assert result["success"] is False
|
|
assert "QR" in result["error"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_qr_in_response(self):
|
|
bridge = MagicMock()
|
|
bridge.get_qr = AsyncMock(return_value={})
|
|
result = await login_with_qr(bridge)
|
|
assert result["success"] is False
|
|
assert "QR" in result["error"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_timeout(self):
|
|
bridge = MagicMock()
|
|
bridge.get_qr = AsyncMock(return_value={"qr": "base64data"})
|
|
|
|
async def slow_scan():
|
|
await asyncio.sleep(3600)
|
|
return {"success": True}
|
|
|
|
bridge.wait_scan = slow_scan
|
|
result = await login_with_qr(bridge, timeout=0.01)
|
|
assert result["success"] is False
|
|
assert result["qr_base64"] == "base64data"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_success(self):
|
|
bridge = MagicMock()
|
|
bridge.get_qr = AsyncMock(return_value={"qr": "base64data"})
|
|
bridge.wait_scan = AsyncMock(return_value={"success": True, "jid": "test@s.whatsapp.net"})
|
|
result = await login_with_qr(bridge)
|
|
assert result["success"] is True
|
|
assert result["jid"] == "test@s.whatsapp.net"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_failed(self):
|
|
bridge = MagicMock()
|
|
bridge.get_qr = AsyncMock(return_value={"qr": "base64data"})
|
|
bridge.wait_scan = AsyncMock(return_value={"success": False, "error": "Scan rejected"})
|
|
result = await login_with_qr(bridge)
|
|
assert result["success"] is False
|
|
assert result["qr_base64"] == "base64data"
|
|
|
|
|
|
class TestProbe:
|
|
@pytest.mark.asyncio
|
|
async def test_probe_bridge(self):
|
|
bridge = MagicMock()
|
|
bridge.health_check = AsyncMock(return_value=HealthStatus(status="healthy"))
|
|
result = await probe_bridge(bridge)
|
|
assert result.status == "healthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_bridge_unhealthy(self):
|
|
bridge = MagicMock()
|
|
bridge.health_check = AsyncMock(return_value=HealthStatus(status="unhealthy", last_error="test error"))
|
|
result = await probe_bridge(bridge)
|
|
assert result.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_connection_success(self):
|
|
bridge = MagicMock()
|
|
bridge.health_check = AsyncMock(
|
|
return_value=HealthStatus(status="healthy", metadata={"jid": "test@s.whatsapp.net"})
|
|
)
|
|
result = await wait_for_connection(bridge, timeout=5.0)
|
|
assert result["connected"] is True
|
|
assert result["jid"] == "test@s.whatsapp.net"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_connection_timeout(self):
|
|
bridge = MagicMock()
|
|
bridge.health_check = AsyncMock(
|
|
return_value=HealthStatus(status="degraded", metadata={})
|
|
)
|
|
result = await wait_for_connection(bridge, timeout=0.01)
|
|
assert result["connected"] is False
|
|
assert "timeout" in result["error"].lower()
|
|
|
|
|
|
class TestCredentialQueue:
|
|
def test_write_auth_creates_file(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
|
|
async def _test():
|
|
count = await queue.write_auth({"test": "data"})
|
|
assert count == 1
|
|
assert (tmp_path / "creds.json").exists()
|
|
data = json.loads((tmp_path / "creds.json").read_text())
|
|
assert data["test"] == "data"
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_write_auth_backup(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
(tmp_path / "creds.json").write_text('{"old": "data"}')
|
|
|
|
async def _test():
|
|
await queue.write_auth({"new": "data"})
|
|
assert (tmp_path / "creds.json.bak").exists()
|
|
backup_data = json.loads((tmp_path / "creds.json.bak").read_text())
|
|
assert backup_data["old"] == "data"
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_write_count_increments(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
|
|
async def _test():
|
|
assert queue.write_count == 0
|
|
await queue.write_auth({"a": 1})
|
|
assert queue.write_count == 1
|
|
await queue.write_auth({"b": 2})
|
|
assert queue.write_count == 2
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_read_auth(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
(tmp_path / "creds.json").write_text('{"key": "value"}')
|
|
|
|
async def _test():
|
|
data = await queue.read_auth()
|
|
assert data is not None
|
|
assert data["key"] == "value"
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_read_auth_nonexistent(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
|
|
async def _test():
|
|
data = await queue.read_auth()
|
|
assert data is None
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_read_auth_corrupt_with_backup(self, tmp_path):
|
|
(tmp_path / "creds.json").write_text("not valid json")
|
|
(tmp_path / "creds.json.bak").write_text('{"restored": true}')
|
|
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
|
|
async def _test():
|
|
data = await queue.read_auth()
|
|
assert data is not None
|
|
assert data["restored"] is True
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_clear_auth(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
(tmp_path / "creds.json").write_text('{"test": 1}')
|
|
|
|
async def _test():
|
|
result = await queue.clear_auth()
|
|
assert result is True
|
|
assert not (tmp_path / "creds.json").exists()
|
|
assert (tmp_path / "creds.json.bak").exists()
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_clear_auth_no_existing(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=100)
|
|
|
|
async def _test():
|
|
result = await queue.clear_auth()
|
|
assert result is True
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_debounce_write(self, tmp_path):
|
|
queue = CredentialQueue(tmp_path, debounce_ms=50)
|
|
|
|
async def _test():
|
|
count1 = await queue.write_auth({"first": 1})
|
|
count2 = await queue.write_auth({"second": 2})
|
|
assert count1 == 1
|
|
assert count2 == 2
|
|
await asyncio.sleep(0.1)
|
|
data = json.loads((tmp_path / "creds.json").read_text())
|
|
assert data["second"] == 2
|
|
|
|
asyncio.run(_test()) |