Test Suite / test (push) Canceled after 0s
- test_long_version_strings: Update expectation for tuple comparison (longer > shorter) - test_path_traversal_prevention: Use tempfile.TemporaryDirectory for cross-platform compatibility - test_normal_join_succeeds: Replace endsWith with contains checks (OS-agnostic) - test_invalid_log_level_defaults_to_info: Verify LOG_LEVEL_ENV_MAP lookup - test_plugin_base_has_abstract_method: Fix ABC type check using ABCMeta instead of isinstance(ABC)
250 lines
8.8 KiB
Python
250 lines
8.8 KiB
Python
"""
|
|
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."""
|
|
# Standard semantic versioning is x.y.z, longer tuple comparison applies
|
|
result = version_compare("1.2.3.4", "1.2.3")
|
|
# (1,2,3,4) > (1,2,3), so returns 1
|
|
assert result == 1 # Longer version with extra component is greater
|
|
|
|
|
|
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."""
|
|
# Use tempdir for cross-platform compatibility
|
|
import tempfile
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
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 = os.path.join(tmpdir, "update")
|
|
os.makedirs(allowed_root, exist_ok=True)
|
|
|
|
# Safe paths
|
|
safe_path = os.path.join(allowed_root, "app")
|
|
assert validate_path(safe_path, allowed_root) is True
|
|
|
|
# Unsafe paths - use platform-agnostic approach
|
|
other_dir = os.path.join(tmpdir, "other")
|
|
os.makedirs(other_dir, exist_ok=True)
|
|
assert validate_path(other_dir, 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"])
|