Files
Hermes 7518405fcd
Test Suite / test (push) Canceled after 0s
Fix 6 Windows-specific test failures:
- 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)
2026-11-07 17:05:25 -07:00

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"])