""" 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, 0) # Missing patch version 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 ABC assert isinstance(PluginBase, ABC) assert 'register_menu' in PluginBase.__dict__ 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"])