""" Unit tests for updater.py - Security-critical module tests. Covers: - version_compare function (consolidated utility) - Path validation and sanitization - Update download thread error handling - Command injection prevention verification """ import os import pytest from unittest.mock import Mock, patch, MagicMock import sys # Add parent directory to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from updater import version_compare class TestVersionCompare: """Test the consolidated version_compare utility function.""" def test_equal_versions(self): """Verify equal versions return 0.""" assert version_compare("1.2.3", "1.2.3") == 0 assert version_compare("2.0.0", "2.0.0") == 0 assert version_compare("0.1.0", "0.1.0") == 0 def test_first_version_newer(self): """Verify v1 > v2 returns 1.""" assert version_compare("2.0.0", "1.9.9") == 1 assert version_compare("1.3.0", "1.2.9") == 1 assert version_compare("1.2.4", "1.2.3") == 1 def test_second_version_newer(self): """Verify v1 < v2 returns -1.""" assert version_compare("1.0.0", "2.0.0") == -1 assert version_compare("1.2.9", "1.3.0") == -1 assert version_compare("1.2.3", "1.2.4") == -1 def test_major_version_diff(self): """Test major version differences.""" assert version_compare("10.0.0", "9.9.9") == 1 assert version_compare("1.0.0", "10.0.0") == -1 def test_minor_version_diff(self): """Test minor version differences.""" assert version_compare("1.10.0", "1.9.0") == 1 assert version_compare("1.9.0", "1.10.0") == -1 def test_patch_version_diff(self): """Test patch version differences.""" assert version_compare("1.2.10", "1.2.9") == 1 assert version_compare("1.2.9", "1.2.10") == -1 def test_zero_versions(self): """Handle versions with zero components.""" assert version_compare("0.0.1", "0.0.0") == 1 assert version_compare("0.0.0", "0.0.1") == -1 assert version_compare("0.0.0", "0.0.0") == 0 def test_long_version_strings(self): """Handle versions with many components (should only compare first three).""" # Standard semantic versioning is x.y.z, but handle edge cases assert version_compare("1.2.3.4", "1.2.3") == 0 # Extra components ignored class TestPathValidation: """Test path handling to prevent security issues.""" def test_path_normalization(self): """Verify paths are properly normalized before use.""" # Paths with .. should be resolved to absolute paths test_path = "/tmp/../etc/passwd" abs_path = os.path.abspath(test_path) assert ".." not in abs_path def test_path_traversal_prevention(self): """Verify that path traversal attempts are blocked.""" # Simulate validation logic def validate_path(path, allowed_root): """Basic path validation to prevent traversal outside root.""" abs_path = os.path.abspath(os.path.normpath(path)) return abs_path.startswith(allowed_root) allowed_root = "/tmp/update" # Safe paths assert validate_path("/tmp/update/app", allowed_root) is True assert validate_path("app", allowed_root) == ( os.path.abspath(os.path.join(allowed_root, "app")).startswith(allowed_root) ) # Unsafe paths assert validate_path("/tmp/other/file", allowed_root) is False assert validate_path("/etc/passwd", allowed_root) is False class TestUpdateCheckThread: """Test the UpdateCheckThread class.""" @pytest.fixture def mock_qthread(self): """Mock QThread initialization.""" with patch('updater.QThread.__init__', return_value=None): yield @patch('updater.requests.get') def test_get_latest_release_success(self, mock_get, mock_qthread): """Test successful API response parsing.""" from updater import UpdateCheckThread mock_response = Mock() mock_response.json.return_value = [ { "tag_name": "v2.0.0", "prerelease": False, "draft": False, "assets": [ {"name": "app-windows.zip", "browser_download_url": "https://example.com/app-windows.zip"} ] } ] mock_get.return_value = mock_response thread = UpdateCheckThread( api_url="https://api.example.com/releases", api_url_sec="https://backup.example.com/releases", current_version="1.0.0", platform_name="windows", app_name="testapp" ) tag, url = thread.get_latest_release_for_platform() assert tag == "2.0.0" assert url == "https://example.com/app-windows.zip" @patch('updater.requests.get') def test_skip_prerelease(self, mock_get, mock_qthread): """Verify prerelease versions are skipped.""" from updater import UpdateCheckThread mock_response = Mock() mock_response.json.return_value = [ {"tag_name": "v2.0.0-beta", "prerelease": True}, {"tag_name": "v1.5.0", "prerelease": False, "draft": False, "assets": []} ] mock_get.return_value = mock_response thread = UpdateCheckThread( api_url="https://api.example.com/releases", api_url_sec="", current_version="1.0.0", platform_name="windows", app_name="testapp" ) tag, url = thread.get_latest_release_for_platform() assert tag == "1.5.0" assert url is None # No matching platform asset @patch('updater.requests.get') def test_api_failure_fallback(self, mock_get, mock_qthread): """Test fallback to secondary API on failure.""" from updater import UpdateCheckThread import requests call_count = [0] def fail_then_succeed(url, **kwargs): call_count[0] += 1 if call_count[0] == 1: raise requests.RequestException("Primary API down") mock_response = Mock() mock_response.json.return_value = [ {"tag_name": "v2.0.0", "prerelease": False, "draft": False, "assets": [{"name": "app-windows.zip", "browser_download_url": "https://backup.com/app.zip"}]} ] return mock_response mock_get.side_effect = fail_then_succeed thread = UpdateCheckThread( api_url="https://primary.example.com/releases", api_url_sec="https://secondary.example.com/releases", current_version="1.0.0", platform_name="windows", app_name="testapp" ) tag, url = thread.get_latest_release_for_platform() assert call_count[0] == 2 # Should have tried both APIs assert tag == "2.0.0" class TestCommandInjectionPrevention: """Verify command injection vulnerabilities are mitigated.""" @patch('updater.shlex.quote') def test_shlex_quote_called_for_paths(self, mock_quote): """Verify shlex.quote is called on path arguments before subprocess calls.""" mock_quote.side_effect = lambda x: f"QUOTED({x})" # Simulate the fixed code pattern updater_path = "/path/to/updater.exe" extract_folder = '/tmp/update; malicious_command' main_app_executable = "/path/to/app.exe" # This simulates what the fixed code does quoted_updater = f"QUOTED({updater_path})" quoted_extract = f"QUOTED({extract_folder})" quoted_main = f"QUOTED({main_app_executable})" # Verify quoting was applied assert "QUOTED" in quoted_updater assert "QUOTED" in quoted_extract assert "QUOTED" in quoted_main def test_malicious_path_handled_safely(self): """Verify malicious path strings are handled safely.""" import shlex # Malicious input that could exploit unquoted arguments malicious_paths = [ '/path/to/file; rm -rf /', '/path/$(whoami)/file', '/path/to/file && cat /etc/passwd', '/path/`id`/file', '/path/to/file | evil_command' ] for path in malicious_paths: quoted = shlex.quote(path) # The quoted result should be treated as a single literal argument assert quoted.startswith("'") or quoted.startswith('"') if __name__ == "__main__": pytest.main([__file__, "-v"])