299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""
|
|
Unit tests for plugin_manager.py - Plugin system and lifecycle management.
|
|
|
|
Covers:
|
|
- PluginManager core functionality
|
|
- SSL context creation (security fix verification)
|
|
- Version parsing logic
|
|
- Platform detection
|
|
- Mock plugin loading tests
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import pytest
|
|
from unittest.mock import Mock, patch, MagicMock, PropertyMock
|
|
from pathlib import Path
|
|
from io import StringIO
|
|
import tempfile
|
|
import json
|
|
|
|
|
|
class TestPluginUtilities:
|
|
"""Test utility functions in plugin_manager.py."""
|
|
|
|
@patch('plugin_manager.ssl')
|
|
@patch('plugin_manager.certifi')
|
|
def test_get_ssl_context_uses_certifi(self, mock_certifi, mock_ssl):
|
|
"""Verify SSL context is created with certifi CA bundle (security fix)."""
|
|
from plugin_manager import get_ssl_context
|
|
|
|
mock_certifi.where.return_value = "/path/to/cacert.pem"
|
|
mock_context = Mock()
|
|
mock_ssl.create_default_context.return_value = mock_context
|
|
|
|
result = get_ssl_context()
|
|
|
|
# Verify certifi.where() was called
|
|
mock_certifi.where.assert_called_once()
|
|
# Verify SSL context was created with cafile parameter
|
|
mock_ssl.create_default_context.assert_called_once_with(
|
|
cafile="/path/to/cacert.pem"
|
|
)
|
|
assert result == mock_context
|
|
|
|
def test_get_current_platform_id_windows(self):
|
|
"""Test platform ID detection for Windows."""
|
|
from plugin_manager import get_current_platform_id
|
|
|
|
with patch('plugin_manager.sys.platform', 'win32'):
|
|
with patch('plugin_manager.platform.machine', return_value='AMD64'):
|
|
assert get_current_platform_id() == "win_x64"
|
|
|
|
with patch('plugin_manager.sys.platform', 'win32'):
|
|
with patch('plugin_manager.platform.machine', return_value='x86'):
|
|
assert get_current_platform_id() == "win_x86"
|
|
|
|
def test_get_current_platform_id_macos(self):
|
|
"""Test platform ID detection for macOS."""
|
|
from plugin_manager import get_current_platform_id
|
|
|
|
# Apple Silicon
|
|
with patch('plugin_manager.sys.platform', 'darwin'):
|
|
with patch('plugin_manager.platform.machine', return_value='arm64'):
|
|
assert get_current_platform_id() == "darwin_arm64"
|
|
|
|
# Intel Mac
|
|
with patch('plugin_manager.sys.platform', 'darwin'):
|
|
with patch('plugin_manager.platform.machine', return_value='x86_64'):
|
|
assert get_current_platform_id() == "darwin_x64"
|
|
|
|
def test_get_current_platform_id_linux(self):
|
|
"""Test platform ID detection for Linux."""
|
|
from plugin_manager import get_current_platform_id
|
|
|
|
with patch('plugin_manager.sys.platform', 'linux'):
|
|
with patch('plugin_manager.platform.machine', return_value='x86_64'):
|
|
assert get_current_platform_id() == "linux_x64"
|
|
|
|
def test_parse_version_basic(self):
|
|
"""Test version string parsing."""
|
|
from plugin_manager import parse_version
|
|
|
|
assert parse_version("1.2.3") == (1, 2, 3)
|
|
assert parse_version("v2.0.0") == (2, 0, 0) # Handle 'v' prefix
|
|
assert parse_version(" 1.0.0 ") == (1, 0, 0) # Handle whitespace
|
|
|
|
def test_parse_version_edge_cases(self):
|
|
"""Test version parsing with invalid input."""
|
|
from plugin_manager import parse_version
|
|
|
|
# Invalid input should return zero tuple
|
|
assert parse_version("invalid") == (0, 0, 0)
|
|
assert parse_version("") == (0, 0, 0)
|
|
assert parse_version("1.2") == (1, 2) # Returns actual parsed length, no padding
|
|
|
|
|
|
class TestPluginManager:
|
|
"""Test the PluginManager class."""
|
|
|
|
@pytest.fixture
|
|
def mock_main_window(self):
|
|
"""Create a mock QMainWindow instance."""
|
|
window = Mock()
|
|
window.menuBar.return_value.actions.return_value = []
|
|
return window
|
|
|
|
@pytest.fixture
|
|
def plugin_manager(self, mock_main_window):
|
|
"""Create a PluginManager instance with mocked dependencies."""
|
|
from plugin_manager import PluginManager
|
|
|
|
with patch('plugin_manager.get_current_platform_id', return_value='linux_x64'):
|
|
with patch('plugin_manager.parse_version', return_value=(1, 8, 0)):
|
|
manager = PluginManager(mock_main_window)
|
|
manager.plugins_dir = Path(tempfile.mkdtemp())
|
|
return manager
|
|
|
|
def test_init_creates_plugins_dir(self, plugin_manager):
|
|
"""Verify plugins directory is created."""
|
|
assert plugin_manager.plugins_dir.exists()
|
|
assert plugin_manager.plugins_dir.is_dir()
|
|
|
|
def test_loaded_plugins_is_list(self, plugin_manager):
|
|
"""Verify loaded_plugins starts as empty list."""
|
|
assert isinstance(plugin_manager.loaded_plugins, list)
|
|
assert len(plugin_manager.loaded_plugins) == 0
|
|
|
|
@patch('plugin_manager.sys.modules', {})
|
|
def test_import_and_instantiate_valid_plugin(self, plugin_manager):
|
|
"""Test loading a valid plugin module."""
|
|
# Create a temporary plugin file
|
|
plugin_code = '''
|
|
class Plugin:
|
|
name = "Test Plugin"
|
|
|
|
def __init__(self, main_window):
|
|
self.main_window = main_window
|
|
|
|
def register_menu(self, menu):
|
|
pass
|
|
'''
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
f.write(plugin_code)
|
|
plugin_file = Path(f.name)
|
|
|
|
try:
|
|
result = plugin_manager._import_and_instantiate("test_plugin", plugin_file)
|
|
assert result is not None
|
|
assert hasattr(result, 'name')
|
|
assert result.name == "Test Plugin"
|
|
finally:
|
|
os.unlink(plugin_file)
|
|
|
|
@patch('plugin_manager.sys.modules', {})
|
|
def test_import_and_instantiate_missing_plugin_class(self, plugin_manager):
|
|
"""Test that modules without Plugin class return None."""
|
|
plugin_code = '''
|
|
# No Plugin class defined here
|
|
some_variable = 42
|
|
'''
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
f.write(plugin_code)
|
|
plugin_file = Path(f.name)
|
|
|
|
try:
|
|
result = plugin_manager._import_and_instantiate("bad_plugin", plugin_file)
|
|
assert result is None # Should return None when no Plugin class
|
|
finally:
|
|
os.unlink(plugin_file)
|
|
|
|
def test_unload_plugins_clears_list(self, plugin_manager):
|
|
"""Test that unload_plugins clears the loaded_plugins list."""
|
|
# Add some mock plugins
|
|
plugin_manager.loaded_plugins = [Mock(), Mock()]
|
|
assert len(plugin_manager.loaded_plugins) == 2
|
|
|
|
plugin_manager.unload_plugins()
|
|
assert len(plugin_manager.loaded_plugins) == 0
|
|
|
|
|
|
class TestPluginMetadata:
|
|
"""Test plugin metadata loading from plugin.json."""
|
|
|
|
@pytest.fixture
|
|
def mock_main_window(self):
|
|
"""Create a mock QMainWindow instance."""
|
|
window = Mock()
|
|
return window
|
|
|
|
def test_metadata_loading_from_json(self, mock_main_window):
|
|
"""Test that plugin.json metadata is properly loaded."""
|
|
from plugin_manager import PluginManager
|
|
|
|
# Create temp directory structure with plugin
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
plugin_dir = Path(tmpdir) / "test_plugin"
|
|
plugin_dir.mkdir()
|
|
|
|
# Create __init__.py with Plugin class
|
|
init_file = plugin_dir / "__init__.py"
|
|
init_file.write_text('''
|
|
class Plugin:
|
|
def __init__(self, main_window):
|
|
self.main_window = main_window
|
|
|
|
def register_menu(self, menu):
|
|
pass
|
|
''')
|
|
|
|
# Create plugin.json with metadata
|
|
manifest = {
|
|
"name": "Custom Name",
|
|
"version": "2.0.0",
|
|
"author": "Test Author",
|
|
"description": "Test Description"
|
|
}
|
|
(plugin_dir / "plugin.json").write_text(json.dumps(manifest))
|
|
|
|
# Add temp dir to path for import
|
|
sys.path.insert(0, tmpdir)
|
|
|
|
try:
|
|
manager = PluginManager(mock_main_window)
|
|
manager.plugins_dir = Path(tmpdir)
|
|
|
|
# Manually load the plugin
|
|
instance = manager._import_and_instantiate("test_plugin", init_file)
|
|
|
|
# Verify metadata was attached
|
|
assert hasattr(instance, 'metadata')
|
|
assert instance.metadata['name'] == "Custom Name"
|
|
assert instance.metadata['version'] == "2.0.0"
|
|
assert instance.metadata['author'] == "Test Author"
|
|
finally:
|
|
sys.path.remove(tmpdir)
|
|
|
|
|
|
class TestPluginInterfaceCompliance:
|
|
"""Test that plugins follow the defined interface specification."""
|
|
|
|
def test_interface_module_exists(self):
|
|
"""Verify plugin interface module was created."""
|
|
# This test verifies the new interface.py exists
|
|
try:
|
|
from src.plugins.interface import PluginBase, AnalysisPlugin, VisualizationPlugin
|
|
assert hasattr(PluginBase, 'register_menu')
|
|
assert hasattr(PluginBase, 'get_metadata')
|
|
except ImportError as e:
|
|
pytest.fail(f"Interface module missing: {e}")
|
|
|
|
def test_plugin_base_has_abstract_method(self):
|
|
"""Verify PluginBase defines required abstract method."""
|
|
from src.plugins.interface import PluginBase
|
|
from abc import ABCMeta
|
|
|
|
# Check that PluginBase is an abstract base class
|
|
assert isinstance(PluginBase, ABCMeta)
|
|
# Verify the abstract method exists
|
|
assert 'register_menu' in PluginBase.__dict__
|
|
assert '__abstractmethods__' in dir(PluginBase)
|
|
|
|
|
|
class TestPathValidation:
|
|
"""Test path handling to prevent security issues (S002)."""
|
|
|
|
def test_plugins_dir_resolves_absolute_path(self):
|
|
"""Verify plugin paths are always resolved to absolute."""
|
|
from plugin_manager import PluginManager
|
|
from unittest.mock import Mock, patch
|
|
|
|
mock_window = Mock()
|
|
|
|
with patch('plugin_manager.Path.cwd', return_value=Path('/current/working/dir')):
|
|
manager = PluginManager(mock_window)
|
|
|
|
# The path should be absolute (no '..' components)
|
|
assert not '..' in str(manager.plugins_dir) or manager.plugins_dir.is_absolute()
|
|
|
|
def test_plugin_path_traversal_blocked(self):
|
|
"""Test that path traversal attempts are blocked."""
|
|
def validate_plugin_path(path: Path, plugins_root: Path) -> bool:
|
|
"""Validate that plugin path is within allowed root."""
|
|
abs_path = path.resolve()
|
|
return str(abs_path).startswith(str(plugins_root.resolve()))
|
|
|
|
plugins_root = Path("/tmp/plugins")
|
|
|
|
# Valid paths should pass
|
|
assert validate_plugin_path(Path("/tmp/plugins/my_plugin"), plugins_root) is True
|
|
assert validate_plugin_path(Path("/tmp/plugins/nested/plugin"), plugins_root) is True
|
|
|
|
# Traversal attempts should fail
|
|
assert validate_plugin_path(Path("/tmp/other/plugin"), plugins_root) is False
|
|
assert validate_plugin_path(Path("/etc/passwd"), plugins_root) is False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|