ForcePilot/backend/test/unit/channels/test_whatsapp_io_credentials.py

338 lines
12 KiB
Python
Raw Normal View History

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