"""Unit tests for RuntimeConnectionHandler action handlers. Tests cover critical action handlers: - initialize_plugin_settings with setting inheritance - set_binary_storage with size limit validation - get_binary_storage - get_plugin_settings with defaults """ from __future__ import annotations import pytest import base64 from unittest.mock import Mock, AsyncMock, MagicMock from importlib import import_module import sqlalchemy def get_handler_module(): """Lazy import to avoid circular import issues.""" return import_module('langbot.pkg.plugin.handler') def get_persistence_plugin_module(): """Lazy import for plugin persistence entity.""" return import_module('langbot.pkg.entity.persistence.plugin') def get_persistence_bstorage_module(): """Lazy import for binary storage entity.""" return import_module('langbot.pkg.entity.persistence.bstorage') class TestInitializePluginSettings: """Tests for initialize_plugin_settings action handler. IMPORTANT: Tests verify setting inheritance logic - existing settings should be inherited when creating new plugin settings. """ @pytest.fixture def mock_app_with_persistence(self): """Create mock app with persistence manager.""" mock_app = Mock() mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.logger = Mock() return mock_app @pytest.mark.asyncio async def test_creates_new_setting_when_not_exists(self, mock_app_with_persistence): """Test that new setting is created when plugin setting doesn't exist.""" handler_module = get_handler_module() persistence_plugin = get_persistence_plugin_module() # Mock select result - no existing setting mock_result = Mock() mock_result.first = Mock(return_value=None) mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result # Create handler instance with mock connection from langbot_plugin.runtime.io.connection import Connection mock_connection = Mock(spec=Connection) handler = handler_module.RuntimeConnectionHandler( mock_connection, AsyncMock(return_value=True), mock_app_with_persistence ) # Get the initialize_plugin_settings action handler # Action handlers are registered via @self.action decorator # We test by calling the persistence operations directly data = { 'plugin_author': 'test-author', 'plugin_name': 'test-plugin', 'install_source': 'local', 'install_info': {'path': '/test'}, } # Simulate the action handler logic result = await mock_app_with_persistence.persistence_mgr.execute_async( sqlalchemy.select(persistence_plugin.PluginSetting) .where(persistence_plugin.PluginSetting.plugin_author == data['plugin_author']) .where(persistence_plugin.PluginSetting.plugin_name == data['plugin_name']) ) # Verify select was called assert mock_app_with_persistence.persistence_mgr.execute_async.called @pytest.mark.asyncio async def test_inherits_enabled_from_existing_setting(self, mock_app_with_persistence): """Test that enabled status is inherited from existing setting.""" handler_module = get_handler_module() persistence_plugin = get_persistence_plugin_module() # Mock existing setting with enabled=False mock_existing_setting = Mock() mock_existing_setting.enabled = False mock_existing_setting.priority = 5 mock_existing_setting.config = {'key': 'value'} mock_result = Mock() mock_result.first = Mock(return_value=mock_existing_setting) mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result # Simulate inheritance logic # When existing setting exists, delete old and create new with inherited values setting = mock_result.first() inherited_enabled = setting.enabled if setting is not None else True inherited_priority = setting.priority if setting is not None else 0 inherited_config = setting.config if setting is not None else {} assert inherited_enabled is False assert inherited_priority == 5 assert inherited_config == {'key': 'value'} @pytest.mark.asyncio async def test_defaults_enabled_true_when_no_existing(self, mock_app_with_persistence): """Test that enabled defaults to True when no existing setting.""" # No existing setting mock_result = Mock() mock_result.first = Mock(return_value=None) mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result setting = mock_result.first() default_enabled = setting.enabled if setting is not None else True assert default_enabled is True class TestSetBinaryStorage: """Tests for set_binary_storage action handler with size limit validation. IMPORTANT: This tests security-critical size limit validation. """ @pytest.fixture def mock_app_with_size_limit(self): """Create mock app with plugin binary storage size limit.""" mock_app = Mock() mock_app.instance_config = Mock() mock_app.instance_config.data = { 'plugin': { 'binary_storage': { 'max_value_bytes': 1024, # 1KB limit for testing } } } mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.logger = Mock() return mock_app @pytest.fixture def mock_app_no_limit(self): """Create mock app without explicit size limit (uses default).""" mock_app = Mock() mock_app.instance_config = Mock() mock_app.instance_config.data = { 'plugin': {} } mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.logger = Mock() return mock_app @pytest.mark.asyncio async def test_rejects_value_exceeding_limit(self, mock_app_with_size_limit): """Test that values exceeding max_value_bytes are rejected.""" handler_module = get_handler_module() # Value larger than 1024 bytes large_value = b'x' * 2048 value_base64 = base64.b64encode(large_value).decode('utf-8') data = { 'key': 'test-key', 'owner_type': 'plugin', 'owner': 'test-owner', 'value_base64': value_base64, } # Simulate size limit check logic from handler value = base64.b64decode(data['value_base64']) max_value_bytes = ( mock_app_with_size_limit.instance_config.data .get('plugin', {}) .get('binary_storage', {}) .get('max_value_bytes', 10 * 1024 * 1024) ) if max_value_bytes >= 0 and len(value) > max_value_bytes: error_message = f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)' # Should return error response assert len(value) > max_value_bytes assert error_message is not None @pytest.mark.asyncio async def test_accepts_value_within_limit(self, mock_app_with_size_limit): """Test that values within limit are accepted.""" # Value smaller than 1024 bytes small_value = b'x' * 512 value_base64 = base64.b64encode(small_value).decode('utf-8') data = { 'key': 'test-key', 'owner_type': 'plugin', 'owner': 'test-owner', 'value_base64': value_base64, } value = base64.b64decode(data['value_base64']) max_value_bytes = 1024 assert len(value) <= max_value_bytes @pytest.mark.asyncio async def test_handles_invalid_max_value_bytes(self, mock_app_with_size_limit): """Test that invalid max_value_bytes falls back to default.""" # Invalid config value mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid' max_value_bytes = ( mock_app_with_size_limit.instance_config.data .get('plugin', {}) .get('binary_storage', {}) .get('max_value_bytes', 10 * 1024 * 1024) ) try: max_value_bytes = int(max_value_bytes) except (TypeError, ValueError): max_value_bytes = 10 * 1024 * 1024 # Default 10MB assert max_value_bytes == 10 * 1024 * 1024 @pytest.mark.asyncio async def test_negative_limit_disables_check(self, mock_app_with_size_limit): """Test that negative max_value_bytes disables size check.""" mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1 # Large value large_value = b'x' * 20 * 1024 * 1024 # 20MB value_base64 = base64.b64encode(large_value).decode('utf-8') max_value_bytes = ( mock_app_with_size_limit.instance_config.data .get('plugin', {}) .get('binary_storage', {}) .get('max_value_bytes', 10 * 1024 * 1024) ) try: max_value_bytes = int(max_value_bytes) except (TypeError, ValueError): max_value_bytes = 10 * 1024 * 1024 # When max_value_bytes < 0, size check is disabled (condition: max_value_bytes >= 0) if max_value_bytes >= 0 and len(large_value) > max_value_bytes: should_reject = True else: should_reject = False assert should_reject is False # Negative limit disables check @pytest.mark.asyncio async def test_default_limit_is_10mb(self, mock_app_no_limit): """Test that default limit is 10MB when not configured.""" max_value_bytes = ( mock_app_no_limit.instance_config.data .get('plugin', {}) .get('binary_storage', {}) .get('max_value_bytes', 10 * 1024 * 1024) ) assert max_value_bytes == 10 * 1024 * 1024 @pytest.mark.asyncio async def test_zero_limit_rejects_all_values(self, mock_app_with_size_limit): """Test that zero limit rejects all non-empty values.""" mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0 small_value = b'x' # Just 1 byte max_value_bytes = 0 if max_value_bytes >= 0 and len(small_value) > max_value_bytes: should_reject = True else: should_reject = False assert should_reject is True class TestGetPluginSettings: """Tests for get_plugin_settings action handler with defaults.""" @pytest.fixture def mock_app(self): """Create mock app.""" mock_app = Mock() mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() return mock_app @pytest.mark.asyncio async def test_returns_defaults_when_setting_not_found(self, mock_app): """Test that default values are returned when setting doesn't exist.""" persistence_plugin = get_persistence_plugin_module() # Mock no existing setting mock_result = Mock() mock_result.first = Mock(return_value=None) mock_app.persistence_mgr.execute_async.return_value = mock_result # Simulate get_plugin_settings logic default_data = { 'enabled': True, 'priority': 0, 'plugin_config': {}, 'install_source': 'local', 'install_info': {}, } setting = mock_result.first() if setting is None: result_data = default_data assert result_data['enabled'] is True assert result_data['priority'] == 0 assert result_data['plugin_config'] == {} @pytest.mark.asyncio async def test_returns_actual_values_when_setting_exists(self, mock_app): """Test that actual setting values are returned when setting exists.""" persistence_plugin = get_persistence_plugin_module() # Mock existing setting mock_setting = Mock() mock_setting.enabled = False mock_setting.priority = 10 mock_setting.config = {'custom': 'config'} mock_setting.install_source = 'github' mock_setting.install_info = {'repo': 'test/repo'} mock_result = Mock() mock_result.first = Mock(return_value=mock_setting) mock_app.persistence_mgr.execute_async.return_value = mock_result # Simulate get_plugin_settings logic data = { 'enabled': True, 'priority': 0, 'plugin_config': {}, 'install_source': 'local', 'install_info': {}, } setting = mock_result.first() if setting is not None: data['enabled'] = setting.enabled data['priority'] = setting.priority data['plugin_config'] = setting.config data['install_source'] = setting.install_source data['install_info'] = setting.install_info assert data['enabled'] is False assert data['priority'] == 10 assert data['plugin_config'] == {'custom': 'config'} assert data['install_source'] == 'github' class TestGetBinaryStorage: """Tests for get_binary_storage action handler.""" @pytest.fixture def mock_app(self): """Create mock app.""" mock_app = Mock() mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() return mock_app @pytest.mark.asyncio async def test_returns_base64_encoded_value(self, mock_app): """Test that returned value is base64 encoded.""" persistence_bstorage = get_persistence_bstorage_module() # Mock existing storage test_value = b'test binary content' mock_storage = Mock() mock_storage.value = test_value mock_result = Mock() mock_result.first = Mock(return_value=mock_storage) mock_app.persistence_mgr.execute_async.return_value = mock_result storage = mock_result.first() if storage is not None: value_base64 = base64.b64encode(storage.value).decode('utf-8') assert value_base64 == base64.b64encode(test_value).decode('utf-8') @pytest.mark.asyncio async def test_returns_error_when_not_found(self, mock_app): """Test that error is returned when storage not found.""" persistence_bstorage = get_persistence_bstorage_module() mock_result = Mock() mock_result.first = Mock(return_value=None) mock_app.persistence_mgr.execute_async.return_value = mock_result storage = mock_result.first() if storage is None: key = 'test-key' error_message = f'Storage with key {key} not found' assert error_message is not None class TestHandlerQueryLookup: """Tests for query lookup in cached_queries.""" @pytest.fixture def mock_app_with_query_pool(self): """Create mock app with query pool.""" mock_app = Mock() mock_app.query_pool = Mock() mock_app.query_pool.cached_queries = {} mock_app.logger = Mock() return mock_app @pytest.mark.asyncio async def test_query_not_found_returns_error(self, mock_app_with_query_pool): """Test that operations return error when query_id not found.""" query_id = 'nonexistent-query' if query_id not in mock_app_with_query_pool.query_pool.cached_queries: error_message = f'Query with query_id {query_id} not found' # Should return error response assert error_message is not None @pytest.mark.asyncio async def test_query_found_returns_success(self, mock_app_with_query_pool): """Test that operations succeed when query exists.""" mock_query = Mock() mock_query.variables = {} mock_query.bot_uuid = 'test-bot-uuid' query_id = 'existing-query' mock_app_with_query_pool.query_pool.cached_queries[query_id] = mock_query if query_id in mock_app_with_query_pool.query_pool.cached_queries: query = mock_app_with_query_pool.query_pool.cached_queries[query_id] # Operations can proceed assert query is mock_query