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())
|