Files
flares/tests/unit/test_plugin_manager.py
T
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

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