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