mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
feat(tenancy): harden shared cloud runtime boundaries
This commit is contained in:
@@ -9,19 +9,32 @@ Tests cover:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
TEST_ACTION_CONTEXT = ActionContext(
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
TEST_INSTALLATION_BINDING = InstallationBinding(
|
||||
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
|
||||
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
|
||||
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
|
||||
|
||||
def get_connector_module():
|
||||
@@ -37,6 +50,7 @@ def create_mock_app():
|
||||
mock_app.instance_config.data = {'plugin': {'enable': True}}
|
||||
mock_app.persistence_mgr = AsyncMock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.persistence_mgr.tenant_uow = None
|
||||
return mock_app
|
||||
|
||||
|
||||
@@ -47,7 +61,19 @@ def create_mock_connector():
|
||||
async def mock_disconnect_callback(conn):
|
||||
pass
|
||||
|
||||
return connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
|
||||
instance = connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
|
||||
instance._execution_context.set(TEST_EXECUTION_CONTEXT)
|
||||
instance._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
|
||||
instance._target_binding = AsyncMock(return_value=TEST_INSTALLATION_BINDING)
|
||||
instance._load_workspace_settings = AsyncMock(return_value=[])
|
||||
instance.require_workspace_context = AsyncMock(side_effect=lambda context: context)
|
||||
return instance
|
||||
|
||||
|
||||
def configure_handler(connector, runtime_handler):
|
||||
runtime_handler.installation_scope = Mock(side_effect=lambda _binding: nullcontext())
|
||||
connector.handler = runtime_handler
|
||||
return runtime_handler
|
||||
|
||||
|
||||
class TestListPlugins:
|
||||
@@ -76,8 +102,7 @@ class TestListPlugins:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
|
||||
)
|
||||
@@ -86,8 +111,7 @@ class TestListPlugins:
|
||||
|
||||
connector.handler.list_plugins.assert_called_once()
|
||||
assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
|
||||
statement = connector.ap.persistence_mgr.execute_async.await_args.args[0]
|
||||
assert 'workspace-a' in statement.compile().params.values()
|
||||
connector._load_workspace_settings.assert_awaited_once_with(TEST_EXECUTION_CONTEXT)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_by_component_kinds(self):
|
||||
@@ -95,8 +119,7 @@ class TestListPlugins:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
@@ -123,8 +146,7 @@ class TestListPlugins:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
@@ -171,7 +193,7 @@ class TestPluginDiagnostics:
|
||||
'response_sources': response_sources,
|
||||
}
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
|
||||
|
||||
fake_event_ctx = Mock()
|
||||
@@ -215,7 +237,7 @@ class TestPluginDiagnostics:
|
||||
],
|
||||
}
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
|
||||
|
||||
fake_event_ctx = Mock()
|
||||
@@ -238,7 +260,7 @@ class TestPluginDiagnostics:
|
||||
connector_module.context.EventContext.from_event = original_from_event
|
||||
connector_module.context.EventContext.model_validate = original_model_validate
|
||||
|
||||
assert '_response_sources' not in vars(event_ctx)
|
||||
assert event_ctx._response_sources == []
|
||||
assert event_ctx._emitted_plugins == [
|
||||
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
|
||||
]
|
||||
@@ -253,7 +275,7 @@ class TestPluginDiagnostics:
|
||||
mock_app = create_mock_app()
|
||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
|
||||
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
|
||||
|
||||
@@ -262,7 +284,7 @@ class TestPluginDiagnostics:
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_plugin_diagnostic_is_best_effort(self):
|
||||
connector = create_mock_connector()
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found'))
|
||||
|
||||
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
|
||||
@@ -297,7 +319,7 @@ class TestListKnowledgeEngines:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_knowledge_engines = AsyncMock(
|
||||
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
|
||||
)
|
||||
@@ -334,7 +356,7 @@ class TestListParsers:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_parsers = AsyncMock(
|
||||
return_value=[{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
|
||||
)
|
||||
@@ -354,7 +376,7 @@ class TestCallParser:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.parse_document = AsyncMock(return_value={'content': 'parsed'})
|
||||
|
||||
result = await connector.call_parser(
|
||||
@@ -381,7 +403,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_ingest_document = AsyncMock(return_value={'status': 'success'})
|
||||
|
||||
result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'})
|
||||
@@ -395,7 +417,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.retrieve_knowledge = AsyncMock(
|
||||
return_value={
|
||||
'results': [
|
||||
@@ -424,7 +446,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.get_rag_creation_schema = AsyncMock(return_value={'properties': {'name': {'type': 'string'}}})
|
||||
|
||||
result = await connector.get_rag_creation_schema('author/engine')
|
||||
@@ -438,7 +460,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.get_rag_retrieval_schema = AsyncMock(
|
||||
return_value={'properties': {'top_k': {'type': 'integer'}}}
|
||||
)
|
||||
@@ -454,7 +476,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_on_kb_create = AsyncMock(return_value={'status': 'ok'})
|
||||
|
||||
await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'})
|
||||
@@ -467,7 +489,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_on_kb_delete = AsyncMock(return_value={'status': 'ok'})
|
||||
|
||||
await connector.rag_on_kb_delete('author/engine', 'kb-uuid')
|
||||
@@ -480,7 +502,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_delete_document = AsyncMock(return_value=True)
|
||||
|
||||
result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid')
|
||||
@@ -574,7 +596,7 @@ class TestGetPluginInfo:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.get_plugin_info = AsyncMock(return_value={'manifest': {'metadata': {'name': 'plugin'}}})
|
||||
|
||||
result = await connector.get_plugin_info('author', 'plugin')
|
||||
@@ -587,17 +609,39 @@ class TestSetPluginConfig:
|
||||
"""Tests for set_plugin_config method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_handler_set_plugin_config(self):
|
||||
"""Test that handler.set_plugin_config is called."""
|
||||
async def test_updates_revision_then_applies_desired_state(self):
|
||||
"""Config changes are fenced by a new runtime revision."""
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.set_plugin_config = AsyncMock(return_value={'status': 'ok'})
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.register_installation_binding = Mock()
|
||||
connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'running'})
|
||||
setting = SimpleNamespace(
|
||||
installation_uuid=TEST_INSTALLATION_BINDING.installation_uuid,
|
||||
runtime_revision=1,
|
||||
artifact_digest=TEST_INSTALLATION_BINDING.artifact_digest,
|
||||
enabled=True,
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
|
||||
)
|
||||
connector._setting_for_plugin = AsyncMock(return_value=(TEST_EXECUTION_CONTEXT, setting))
|
||||
connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
|
||||
|
||||
await connector.set_plugin_config('author', 'plugin', {'setting': 'value'})
|
||||
|
||||
connector.handler.set_plugin_config.assert_called_once_with('author', 'plugin', {'setting': 'value'})
|
||||
applied_binding = connector.handler.apply_plugin_installation.await_args.args[0]
|
||||
assert applied_binding.runtime_revision == 2
|
||||
assert applied_binding.installation_uuid == TEST_INSTALLATION_BINDING.installation_uuid
|
||||
connector.handler.register_installation_binding.assert_called_once_with(
|
||||
applied_binding,
|
||||
plugin_author='author',
|
||||
plugin_name='plugin',
|
||||
)
|
||||
connector.handler.apply_plugin_installation.assert_awaited_once_with(
|
||||
applied_binding,
|
||||
artifact_package=None,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
class TestPingPluginRuntime:
|
||||
@@ -621,7 +665,7 @@ class TestPingPluginRuntime:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.ping = AsyncMock(return_value={'status': 'ok'})
|
||||
|
||||
await connector.ping_plugin_runtime()
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.runtime.security import (
|
||||
PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
|
||||
PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
|
||||
@@ -91,7 +90,7 @@ async def test_enabled_connector_reports_not_connected_after_workspace_validatio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_resolves_oss_singleton_binding_from_workspace_service():
|
||||
async def test_oss_connector_resolves_singleton_only_for_legacy_callers():
|
||||
connector = make_connector()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
get_local_execution_binding=AsyncMock(
|
||||
@@ -103,31 +102,29 @@ async def test_connector_resolves_oss_singleton_binding_from_workspace_service()
|
||||
)
|
||||
)
|
||||
|
||||
assert await connector._resolve_action_context() == ActionContext(
|
||||
assert await connector._current_execution_context() == ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edition_metadata_cannot_enable_multi_workspace_runtime_binding():
|
||||
def test_edition_metadata_cannot_enable_shared_runtime_profile():
|
||||
connector = make_connector()
|
||||
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=False),
|
||||
get_local_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
),
|
||||
|
||||
assert connector.runtime_profile == 'oss_dev'
|
||||
|
||||
|
||||
def test_closed_deployment_selects_instance_scoped_shared_profile():
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
|
||||
context = await connector._resolve_action_context()
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
assert context.workspace_uuid == 'workspace-a'
|
||||
assert connector.runtime_profile == 'shared'
|
||||
|
||||
|
||||
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
||||
@@ -148,48 +145,61 @@ def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_fails_closed_without_trusted_workspace_service():
|
||||
async def test_oss_legacy_fallback_fails_without_workspace_service():
|
||||
connector = make_connector()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='Plugin Runtime Workspace binding is unavailable',
|
||||
):
|
||||
await connector._resolve_action_context()
|
||||
with pytest.raises(AttributeError):
|
||||
await connector._current_execution_context()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_connector_never_falls_back_to_ghost_local_workspace():
|
||||
connector = make_connector()
|
||||
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
get_local_binding = AsyncMock()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
app.workspace_service = SimpleNamespace(
|
||||
get_local_execution_binding=get_local_binding,
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='require an explicit projected Workspace binding',
|
||||
):
|
||||
await connector._resolve_action_context()
|
||||
with pytest.raises(Exception, match='Plugin resource not found'):
|
||||
await connector._current_execution_context()
|
||||
|
||||
get_local_binding.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_connector_initialize_fails_before_opening_unbound_runtime():
|
||||
connector = make_connector()
|
||||
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
def test_worker_policy_is_loaded_only_from_instance_configuration():
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'plugin': {
|
||||
'enable': True,
|
||||
'worker': {
|
||||
'max_cpus': 1.5,
|
||||
'max_memory_mb': 768,
|
||||
'max_pids': 64,
|
||||
'max_open_files': 128,
|
||||
'max_file_size_mb': 32,
|
||||
'require_hard_limits': True,
|
||||
},
|
||||
# A plugin-controlled value at any other path is ignored.
|
||||
'manifest': {'max_memory_mb': 99999},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='require an explicit projected Workspace binding',
|
||||
):
|
||||
await connector.initialize()
|
||||
policy = connector._load_worker_policy()
|
||||
|
||||
assert policy.max_cpus == 1.5
|
||||
assert policy.max_memory_mb == 768
|
||||
assert policy.max_pids == 64
|
||||
assert policy.max_open_files == 128
|
||||
assert policy.max_file_size_mb == 32
|
||||
assert policy.require_hard_limits is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -198,17 +208,11 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'plugin': {'enable': True},
|
||||
'system': {'edition': 'cloud'},
|
||||
}
|
||||
)
|
||||
)
|
||||
configured = ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-cloud-a',
|
||||
placement_generation=7,
|
||||
),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
app.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
@@ -216,13 +220,19 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
|
||||
placement_generation=7,
|
||||
)
|
||||
),
|
||||
get_local_execution_binding=AsyncMock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock(), action_context=configured)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = SimpleNamespace()
|
||||
connector._synchronize_workspace = AsyncMock()
|
||||
configured = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-cloud-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
assert await connector._resolve_action_context() == configured
|
||||
assert await connector.require_workspace_context(configured) == configured
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'workspace-cloud-a',
|
||||
expected_generation=7,
|
||||
)
|
||||
app.workspace_service.get_local_execution_binding.assert_not_awaited()
|
||||
connector._synchronize_workspace.assert_awaited_once_with(configured)
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.plugin.connector import (
|
||||
PluginInstallationFailedError,
|
||||
PluginRuntimeConnector,
|
||||
)
|
||||
|
||||
|
||||
def connection_result_connector(execute_async: AsyncMock) -> PluginRuntimeConnector:
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
persistence_mgr=SimpleNamespace(
|
||||
tenant_uow=None,
|
||||
execute_async=execute_async,
|
||||
),
|
||||
logger=Mock(),
|
||||
)
|
||||
return PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
|
||||
def execution_binding(workspace_uuid: str, generation: int = 1) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
)
|
||||
|
||||
|
||||
def plugin_setting(
|
||||
workspace_suffix: str,
|
||||
artifact_digest: str,
|
||||
*,
|
||||
durable: bool = True,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
plugin_author='author',
|
||||
plugin_name=f'plugin-{workspace_suffix}',
|
||||
installation_uuid=f'00000000-0000-4000-8000-0000000000{workspace_suffix}',
|
||||
runtime_revision=1,
|
||||
artifact_digest=artifact_digest,
|
||||
enabled=True,
|
||||
priority=0,
|
||||
created_at=datetime.datetime(2026, 1, 1),
|
||||
install_source='local',
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if durable else {},
|
||||
)
|
||||
|
||||
|
||||
def runtime_handler(
|
||||
*,
|
||||
missing_artifacts: list[str] | None = None,
|
||||
failed_installations: list[dict[str, str]] | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
register_installation_binding=Mock(),
|
||||
unregister_installation_binding=Mock(),
|
||||
reconcile_plugin_installations=AsyncMock(
|
||||
return_value={
|
||||
'applied': [],
|
||||
'removed': [],
|
||||
'missing_artifacts': missing_artifacts or [],
|
||||
'failed_installations': failed_installations or [],
|
||||
}
|
||||
),
|
||||
apply_plugin_installation=AsyncMock(return_value={'state': 'starting'}),
|
||||
installation_scope=Mock(side_effect=lambda _binding: nullcontext()),
|
||||
list_plugins=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
|
||||
def shared_connector(
|
||||
projected_bindings: list[list[SimpleNamespace]],
|
||||
settings: dict[str, list[SimpleNamespace]],
|
||||
) -> PluginRuntimeConnector:
|
||||
async def get_execution_binding(workspace_uuid: str, *, expected_generation: int | None = None):
|
||||
for binding_set in projected_bindings:
|
||||
for binding in binding_set:
|
||||
if binding.workspace_uuid == workspace_uuid:
|
||||
assert expected_generation in (None, binding.placement_generation)
|
||||
return binding
|
||||
raise AssertionError(f'unexpected Workspace {workspace_uuid}')
|
||||
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
workspace_service=SimpleNamespace(
|
||||
list_active_execution_bindings=AsyncMock(side_effect=projected_bindings),
|
||||
get_execution_binding=AsyncMock(side_effect=get_execution_binding),
|
||||
),
|
||||
persistence_mgr=SimpleNamespace(tenant_uow=None),
|
||||
logger=Mock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector._load_workspace_settings = AsyncMock(side_effect=lambda context: settings[context.workspace_uuid])
|
||||
return connector
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
binding_b = execution_binding('workspace-b')
|
||||
setting_a = plugin_setting('01', 'a' * 64)
|
||||
setting_b = plugin_setting('02', 'b' * 64)
|
||||
connector = shared_connector(
|
||||
[[binding_a, binding_b], [binding_a]],
|
||||
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
|
||||
)
|
||||
|
||||
first_handler = runtime_handler()
|
||||
connector.handler = first_handler
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
first_desired = first_handler.reconcile_plugin_installations.await_args.args[0]
|
||||
assert {state.binding.workspace_uuid for state in first_desired} == {'workspace-a', 'workspace-b'}
|
||||
|
||||
second_handler = runtime_handler()
|
||||
connector.handler = second_handler
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
second_desired = second_handler.reconcile_plugin_installations.await_args.args[0]
|
||||
assert [state.binding.workspace_uuid for state in second_desired] == ['workspace-a']
|
||||
second_handler.unregister_installation_binding.assert_called_once_with(first_desired[1].binding)
|
||||
assert set(connector._workspace_installations) == {'workspace-a'}
|
||||
assert set(connector._known_desired_states) == {setting_a.installation_uuid}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
|
||||
package = b'local-lbpkg-bytes'
|
||||
digest = hashlib.sha256(package).hexdigest()
|
||||
binding = execution_binding('workspace-a')
|
||||
setting = plugin_setting('01', digest)
|
||||
connector = shared_connector([[binding]], {'workspace-a': [setting]})
|
||||
connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
|
||||
connector._load_artifact_package = AsyncMock(return_value=package)
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
desired = connector.handler.reconcile_plugin_installations.await_args.args[0][0]
|
||||
connector._load_artifact_package.assert_awaited_once()
|
||||
connector.handler.apply_plugin_installation.assert_awaited_once_with(
|
||||
desired.binding,
|
||||
artifact_package=package,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_upgrade_keeps_legacy_data_plugins_when_no_lbpkg_was_backfilled():
|
||||
binding = execution_binding('workspace-a')
|
||||
setting = plugin_setting('01', hashlib.sha256(b'legacy-installation').hexdigest(), durable=False)
|
||||
legacy_plugin = {
|
||||
'debug': False,
|
||||
'manifest': {'manifest': {'metadata': {'author': 'author', 'name': 'plugin-01'}}},
|
||||
'components': [],
|
||||
}
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='oss'),
|
||||
workspace_service=SimpleNamespace(get_local_execution_binding=AsyncMock(return_value=binding)),
|
||||
persistence_mgr=SimpleNamespace(tenant_uow=None),
|
||||
logger=Mock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
|
||||
connector.handler.list_plugins = AsyncMock(return_value=[legacy_plugin])
|
||||
connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'artifact_missing'})
|
||||
connector._load_workspace_settings = AsyncMock(return_value=[setting])
|
||||
connector._load_artifact_package = AsyncMock(return_value=None)
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
plugins = await connector.list_plugins()
|
||||
|
||||
assert plugins == [legacy_plugin]
|
||||
assert connector.handler.list_plugins.await_count >= 2
|
||||
connector._load_artifact_package.assert_awaited_once()
|
||||
assert not hasattr(connector.handler, 'delete_plugin')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_install_persists_verified_package_before_runtime_apply():
|
||||
package = b'local-lbpkg-bytes'
|
||||
digest = hashlib.sha256(package).hexdigest()
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
binding = InstallationBinding(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest=digest,
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
logger=Mock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = runtime_handler()
|
||||
connector._current_execution_context = AsyncMock(return_value=execution_context)
|
||||
connector._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
|
||||
connector._store_artifact_package = AsyncMock()
|
||||
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
|
||||
connector._wait_for_installed_plugin_ready = AsyncMock()
|
||||
|
||||
await connector.install_plugin(
|
||||
PluginInstallSource.LOCAL,
|
||||
{'plugin_file': package},
|
||||
)
|
||||
|
||||
connector._store_artifact_package.assert_awaited_once_with(execution_context, digest, package)
|
||||
connector.handler.apply_plugin_installation.assert_awaited_once_with(
|
||||
binding,
|
||||
artifact_package=package,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
|
||||
async def test_artifact_cleanup_is_reference_counted_within_workspace(
|
||||
remaining_references: int,
|
||||
statement_count: int,
|
||||
):
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
statements = []
|
||||
|
||||
async def execute(statement):
|
||||
statements.append(statement)
|
||||
return SimpleNamespace(scalar_one=lambda: remaining_references)
|
||||
|
||||
await connector._delete_artifact_if_unreferenced(
|
||||
execution_context,
|
||||
'a' * 64,
|
||||
execute=execute,
|
||||
)
|
||||
|
||||
assert len(statements) == statement_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_store_and_load_accept_connection_scalar_results():
|
||||
package = b'persisted-lbpkg'
|
||||
digest = hashlib.sha256(package).hexdigest()
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
execute_async = AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: package))
|
||||
connector = connection_result_connector(execute_async)
|
||||
|
||||
await connector._store_artifact_package(execution_context, digest, package)
|
||||
loaded = await connector._load_artifact_package(execution_context, digest)
|
||||
|
||||
assert loaded == package
|
||||
assert execute_async.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_settings_accept_connection_mapping_results():
|
||||
created_at = datetime.datetime(2026, 1, 1)
|
||||
row = {
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'plugin_author': 'author',
|
||||
'plugin_name': 'plugin',
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000001',
|
||||
'artifact_digest': 'a' * 64,
|
||||
'runtime_revision': 2,
|
||||
'enabled': True,
|
||||
'priority': 3,
|
||||
'config': {'key': 'value'},
|
||||
'install_source': 'local',
|
||||
'install_info': {'_artifact_storage': 'tenant_binary_storage_v1'},
|
||||
'created_at': created_at,
|
||||
'updated_at': created_at,
|
||||
}
|
||||
mapped_result = SimpleNamespace(mappings=lambda: SimpleNamespace(all=lambda: [row]))
|
||||
connector = connection_result_connector(AsyncMock(return_value=mapped_result))
|
||||
|
||||
settings = await connector._load_workspace_settings(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(settings) == 1
|
||||
assert settings[0].installation_uuid == row['installation_uuid']
|
||||
assert settings[0].runtime_revision == 2
|
||||
assert settings[0].created_at == created_at
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installation_update_accepts_connection_row_result():
|
||||
old_digest = 'a' * 64
|
||||
new_digest = 'b' * 64
|
||||
existing = SimpleNamespace(
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=4,
|
||||
artifact_digest=old_digest,
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
|
||||
)
|
||||
execute_async = AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(first=lambda: existing),
|
||||
SimpleNamespace(rowcount=1),
|
||||
]
|
||||
)
|
||||
connector = connection_result_connector(execute_async)
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
binding, previous_digest, previous_was_durable = await connector._persist_installation_package(
|
||||
execution_context,
|
||||
plugin_author='author',
|
||||
plugin_name='plugin',
|
||||
install_source=PluginInstallSource.LOCAL,
|
||||
install_info={},
|
||||
artifact_digest=new_digest,
|
||||
)
|
||||
|
||||
assert binding.installation_uuid == existing.installation_uuid
|
||||
assert binding.runtime_revision == 5
|
||||
assert binding.artifact_digest == new_digest
|
||||
assert previous_digest == old_digest
|
||||
assert previous_was_durable is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_dependency_failure_raises_stable_observable_error():
|
||||
binding = execution_binding('workspace-a')
|
||||
setting = plugin_setting('01', 'a' * 64)
|
||||
connector = shared_connector([[binding]], {'workspace-a': [setting]})
|
||||
connector.handler = runtime_handler()
|
||||
desired = (
|
||||
await connector._load_workspace_desired_states(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
)
|
||||
)[0]
|
||||
connector.handler.apply_plugin_installation.return_value = {
|
||||
'installation_uuid': setting.installation_uuid,
|
||||
'state': 'failed',
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
|
||||
with pytest.raises(PluginInstallationFailedError) as exc_info:
|
||||
await connector._apply_desired_state(desired)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.installation_uuid == setting.installation_uuid
|
||||
assert error.error_code == 'dependency_prepare_failed'
|
||||
assert '[dependency_prepare_failed]' in str(error)
|
||||
assert connector._installation_failures[setting.installation_uuid] == {
|
||||
'installation_uuid': setting.installation_uuid,
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
connector.ap.logger.error.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconcile_records_one_failure_without_blocking_other_state():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
binding_b = execution_binding('workspace-b')
|
||||
setting_a = plugin_setting('01', 'a' * 64)
|
||||
setting_b = plugin_setting('02', 'b' * 64)
|
||||
failure = {
|
||||
'installation_uuid': setting_a.installation_uuid,
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
connector = shared_connector(
|
||||
[[binding_a, binding_b]],
|
||||
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
|
||||
)
|
||||
connector.handler = runtime_handler(failed_installations=[failure])
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
desired = connector.handler.reconcile_plugin_installations.await_args.args[0]
|
||||
assert {item.binding.installation_uuid for item in desired} == {
|
||||
setting_a.installation_uuid,
|
||||
setting_b.installation_uuid,
|
||||
}
|
||||
assert connector._installation_failures == {
|
||||
setting_a.installation_uuid: failure,
|
||||
}
|
||||
assert set(connector._known_desired_states) == {
|
||||
setting_a.installation_uuid,
|
||||
setting_b.installation_uuid,
|
||||
}
|
||||
connector.ap.logger.error.assert_called_once_with(
|
||||
'Plugin installation %s failed during reconcile [%s]: %s',
|
||||
setting_a.installation_uuid,
|
||||
'dependency_prepare_failed',
|
||||
'Plugin dependency installer exited with code 1',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_artifact_repair_adds_dependency_failure_and_continues():
|
||||
package_a = b'package-a'
|
||||
package_b = b'package-b'
|
||||
binding = execution_binding('workspace-a')
|
||||
setting_a = plugin_setting('01', hashlib.sha256(package_a).hexdigest())
|
||||
setting_b = plugin_setting('02', hashlib.sha256(package_b).hexdigest())
|
||||
connector = shared_connector(
|
||||
[[binding]],
|
||||
{'workspace-a': [setting_a, setting_b]},
|
||||
)
|
||||
connector.handler = runtime_handler(
|
||||
missing_artifacts=[
|
||||
setting_a.installation_uuid,
|
||||
setting_b.installation_uuid,
|
||||
]
|
||||
)
|
||||
connector._load_artifact_package = AsyncMock(side_effect=[package_a, package_b])
|
||||
connector.handler.apply_plugin_installation.side_effect = [
|
||||
{
|
||||
'installation_uuid': setting_a.installation_uuid,
|
||||
'state': 'failed',
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
},
|
||||
{'installation_uuid': setting_b.installation_uuid, 'state': 'starting'},
|
||||
]
|
||||
|
||||
result = await connector.reconcile_projected_workspaces(
|
||||
[
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert connector.handler.apply_plugin_installation.await_count == 2
|
||||
assert result['failed_installations'] == [
|
||||
{
|
||||
'installation_uuid': setting_a.installation_uuid,
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
]
|
||||
assert setting_a.installation_uuid in connector._installation_failures
|
||||
assert setting_b.installation_uuid not in connector._installation_failures
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.plugin import connector as connector_module
|
||||
|
||||
from .test_connector_methods import create_mock_connector
|
||||
|
||||
|
||||
TRUSTED_LEGACY_REQUEST = {
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
'asset_url': 'https://github.com/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
|
||||
}
|
||||
|
||||
|
||||
def _patch_client(monkeypatch, handler):
|
||||
real_async_client = httpx.AsyncClient
|
||||
observed: list[dict] = []
|
||||
|
||||
def client_factory(*args, **kwargs):
|
||||
observed.append(dict(kwargs))
|
||||
return real_async_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
follow_redirects=kwargs.get('follow_redirects', False),
|
||||
trust_env=kwargs.get('trust_env', True),
|
||||
timeout=kwargs.get('timeout'),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(connector_module.httpx, 'AsyncClient', client_factory)
|
||||
return observed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'asset_url',
|
||||
[
|
||||
'http://127.0.0.1/internal.lbpkg',
|
||||
'https://169.254.169.254/latest/meta-data',
|
||||
'https://github.com@127.0.0.1/internal.lbpkg',
|
||||
'https://evil.example/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_install_rejects_internal_or_untrusted_asset_url_before_network(
|
||||
monkeypatch,
|
||||
asset_url,
|
||||
):
|
||||
connector = create_mock_connector()
|
||||
monkeypatch.setattr(
|
||||
connector_module.httpx,
|
||||
'AsyncClient',
|
||||
lambda *_args, **_kwargs: pytest.fail('network client must not be created'),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='GitHub release asset URL'):
|
||||
await connector._download_github_package(
|
||||
{**TRUSTED_LEGACY_REQUEST, 'asset_url': asset_url},
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_asset_id_is_resolved_server_side_and_redirect_escape_is_rejected(
|
||||
monkeypatch,
|
||||
):
|
||||
connector = create_mock_connector()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith('/releases/42'):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'id': 42,
|
||||
'tag_name': 'v1.0.0',
|
||||
'assets': [{'id': 99, 'size': 128, 'state': 'uploaded'}],
|
||||
},
|
||||
)
|
||||
if request.url.path.endswith('/releases/assets/99'):
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={'location': 'http://169.254.169.254/latest/meta-data'},
|
||||
)
|
||||
raise AssertionError(f'unexpected request: {request.url}')
|
||||
|
||||
observed = _patch_client(monkeypatch, handler)
|
||||
with pytest.raises(ValueError, match='untrusted host'):
|
||||
await connector._download_github_package(
|
||||
{
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
'release_id': 42,
|
||||
'asset_id': 99,
|
||||
'asset_url': 'https://attacker.invalid/ignored',
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
assert len(observed) == 1
|
||||
assert observed[0]['trust_env'] is False
|
||||
assert observed[0]['follow_redirects'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_download_rejects_oversized_content_length(monkeypatch):
|
||||
connector = create_mock_connector()
|
||||
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, headers={'content-length': '9'}, content=b'')
|
||||
|
||||
_patch_client(monkeypatch, handler)
|
||||
with pytest.raises(ValueError, match='10 MiB download limit'):
|
||||
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_download_counts_chunked_stream_bytes(monkeypatch):
|
||||
connector = create_mock_connector()
|
||||
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
|
||||
|
||||
class ChunkedBody(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield b'1234'
|
||||
yield b'56789'
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, stream=ChunkedBody())
|
||||
|
||||
_patch_client(monkeypatch, handler)
|
||||
with pytest.raises(ValueError, match='10 MiB download limit'):
|
||||
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_asset_id_download_allows_github_object_redirect(monkeypatch):
|
||||
connector = create_mock_connector()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith('/releases/42'):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'id': 42,
|
||||
'tag_name': 'v1.0.0',
|
||||
'assets': [{'id': 99, 'size': 7, 'state': 'uploaded'}],
|
||||
},
|
||||
)
|
||||
if request.url.path.endswith('/releases/assets/99'):
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={
|
||||
'location': 'https://release-assets.githubusercontent.com/github-production-release-asset/demo'
|
||||
},
|
||||
)
|
||||
if request.url.host == 'release-assets.githubusercontent.com':
|
||||
return httpx.Response(200, content=b'package')
|
||||
raise AssertionError(f'unexpected request: {request.url}')
|
||||
|
||||
_patch_client(monkeypatch, handler)
|
||||
package = await connector._download_github_package(
|
||||
{
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
'release_id': 42,
|
||||
'asset_id': 99,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
assert package == b'package'
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
@@ -35,14 +35,19 @@ def make_handler(app):
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
workspace_context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
workspace_context,
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
installation_binding = InstallationBinding(
|
||||
**workspace_context.model_dump(exclude_none=True),
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
|
||||
runtime_handler.register_installation_binding(
|
||||
installation_binding,
|
||||
plugin_author='test-author',
|
||||
plugin_name='test-plugin',
|
||||
)
|
||||
runtime_handler._current_action_context.set(installation_binding)
|
||||
query_pool = getattr(app, 'query_pool', None)
|
||||
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.storage.mgr import StorageMgr
|
||||
@@ -52,14 +52,19 @@ def make_handler(app, workspace_context: ActionContext | None = None):
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
workspace_context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
workspace_context,
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
installation_binding = InstallationBinding(
|
||||
**workspace_context.model_dump(exclude_none=True),
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
|
||||
runtime_handler.register_installation_binding(
|
||||
installation_binding,
|
||||
plugin_author='test-author',
|
||||
plugin_name='test-plugin',
|
||||
)
|
||||
runtime_handler._current_action_context.set(installation_binding)
|
||||
query_pool = getattr(app, 'query_pool', None)
|
||||
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
|
||||
|
||||
@@ -169,13 +174,10 @@ class TestInitializePluginSettings:
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_setting_when_not_exists(self, app):
|
||||
"""New plugin settings use default enabled, priority and config values."""
|
||||
async def test_rejects_desired_installation_when_setting_not_exists(self, app):
|
||||
"""A desired-state worker cannot create an unowned Core setting row."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
Mock(),
|
||||
]
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
@@ -186,34 +188,23 @@ class TestInitializePluginSettings:
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert insert_params == {
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'local',
|
||||
'install_info': {'path': '/test'},
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'config': {},
|
||||
}
|
||||
assert response.code != 0
|
||||
assert 'Plugin installation setting was not found' in response.message
|
||||
app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherits_values_from_existing_setting(self, app):
|
||||
"""Existing settings are replaced while preserving user-controlled values."""
|
||||
async def test_existing_desired_setting_remains_core_owned(self, app):
|
||||
"""Runtime initialization validates identity without rewriting Core state."""
|
||||
runtime_handler = make_handler(app)
|
||||
existing_setting = SimpleNamespace(
|
||||
enabled=False,
|
||||
priority=5,
|
||||
config={'key': 'value'},
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(existing_setting),
|
||||
Mock(),
|
||||
Mock(),
|
||||
]
|
||||
app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
@@ -225,13 +216,7 @@ class TestInitializePluginSettings:
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert insert_params['enabled'] is False
|
||||
assert insert_params['priority'] == 5
|
||||
assert insert_params['config'] == {'key': 'value'}
|
||||
assert insert_params['install_source'] == 'github'
|
||||
assert insert_params['install_info'] == {'repo': 'author/name'}
|
||||
app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
|
||||
|
||||
class TestSetBinaryStorage:
|
||||
@@ -367,31 +352,18 @@ class TestGetPluginSettings:
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_defaults_when_setting_not_found(self, app):
|
||||
"""Default plugin settings are returned when no persisted row exists."""
|
||||
async def test_rejects_desired_installation_when_setting_not_found(self, app):
|
||||
"""A desired-state worker cannot synthesize settings for a missing row."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'plugin_config': {},
|
||||
'install_source': 'local',
|
||||
'install_info': {},
|
||||
'installation_uuid': runtime_handler.derive_installation_uuid(
|
||||
runtime_handler.require_bound_action_context(),
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
),
|
||||
}
|
||||
with pytest.raises(ValueError, match='Plugin installation setting was not found'):
|
||||
await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_actual_values_when_setting_exists(self, app):
|
||||
@@ -403,6 +375,9 @@ class TestGetPluginSettings:
|
||||
config={'custom': 'config'},
|
||||
install_source='github',
|
||||
install_info={'repo': 'test/repo'},
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(setting)
|
||||
|
||||
@@ -420,11 +395,9 @@ class TestGetPluginSettings:
|
||||
'plugin_config': {'custom': 'config'},
|
||||
'install_source': 'github',
|
||||
'install_info': {'repo': 'test/repo'},
|
||||
'installation_uuid': runtime_handler.derive_installation_uuid(
|
||||
runtime_handler.require_bound_action_context(),
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
),
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000001',
|
||||
'runtime_revision': 1,
|
||||
'artifact_digest': 'a' * 64,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
|
||||
from langbot_plugin.entities.io.resp import ActionResponse
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
|
||||
|
||||
class EmptyResult:
|
||||
@@ -49,6 +52,7 @@ def workspace_context(workspace_uuid: str = 'workspace-a') -> ActionContext:
|
||||
def make_handler(workspace_uuid: str = 'workspace-a'):
|
||||
context = workspace_context(workspace_uuid)
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=EmptyResult())),
|
||||
logger=Mock(),
|
||||
workspace_service=SimpleNamespace(
|
||||
@@ -65,14 +69,19 @@ def make_handler(workspace_uuid: str = 'workspace-a'):
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
context,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
installation_context = InstallationBinding(
|
||||
**context.model_dump(exclude_none=True),
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
return runtime_handler, app, context.for_installation(installation_uuid)
|
||||
runtime_handler.register_installation_binding(
|
||||
installation_context,
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
)
|
||||
return runtime_handler, app, installation_context
|
||||
|
||||
|
||||
async def invoke_with_context(
|
||||
@@ -92,10 +101,98 @@ async def invoke_with_context(
|
||||
async def test_plugin_action_requires_installation_capability():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
|
||||
with pytest.raises(ValueError, match='missing installation_uuid'):
|
||||
with pytest.raises(ValueError, match='trusted Workspace context'):
|
||||
await runtime_handler.actions[PluginToRuntimeAction.GET_LANGBOT_VERSION.value]({})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_action_enters_trusted_workspace_scope():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
scope_events: list[tuple[str, str]] = []
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def tenant_scope(workspace_uuid: str):
|
||||
scope_events.append(('enter', workspace_uuid))
|
||||
try:
|
||||
yield SimpleNamespace()
|
||||
finally:
|
||||
scope_events.append(('exit', workspace_uuid))
|
||||
|
||||
class RecordingPersistenceManager:
|
||||
def __init__(self, execute_async):
|
||||
self.execute_async = execute_async
|
||||
|
||||
def tenant_scope(self, workspace_uuid: str):
|
||||
return tenant_scope(workspace_uuid)
|
||||
|
||||
app.persistence_mgr = RecordingPersistenceManager(app.persistence_mgr.execute_async)
|
||||
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION,
|
||||
{},
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert scope_events == [('enter', 'workspace-a'), ('exit', 'workspace-a')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_llm_provider_does_not_hold_tenant_database_session():
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
observations: list[bool] = []
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
app.persistence_mgr = manager
|
||||
|
||||
async def invoke_llm(**_kwargs):
|
||||
observations.append(manager.current_session() is None)
|
||||
entered.set()
|
||||
await release.wait()
|
||||
observations.append(manager.current_session() is None)
|
||||
return SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'ok'})
|
||||
|
||||
app.model_mgr = SimpleNamespace(
|
||||
get_model_by_uuid=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
model_entity=SimpleNamespace(workspace_uuid='workspace-a'),
|
||||
provider=SimpleNamespace(invoke_llm=invoke_llm),
|
||||
)
|
||||
)
|
||||
)
|
||||
runtime_handler._require_plugin_action_context = AsyncMock(return_value=(installation_context, SimpleNamespace()))
|
||||
runtime_handler._require_active_action_context = AsyncMock()
|
||||
runtime_handler._resource_exists = AsyncMock(return_value=True)
|
||||
|
||||
action = asyncio.create_task(
|
||||
invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.INVOKE_LLM,
|
||||
{
|
||||
'llm_model_uuid': 'model-a',
|
||||
'messages': [],
|
||||
},
|
||||
)
|
||||
)
|
||||
try:
|
||||
await entered.wait()
|
||||
assert observations == [True]
|
||||
release.set()
|
||||
response = await action
|
||||
assert response.code == 0
|
||||
assert observations == [True, True]
|
||||
finally:
|
||||
release.set()
|
||||
if not action.done():
|
||||
await action
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_action_rejects_forged_installation_capability():
|
||||
runtime_handler, _app, installation_context = make_handler()
|
||||
@@ -203,14 +300,12 @@ async def test_legacy_query_id_fallback_is_workspace_scoped():
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_connection_rejects_workspace_rebinding():
|
||||
def test_runtime_connection_is_instance_scoped_and_unbound():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
|
||||
with pytest.raises(ValueError, match='another Workspace'):
|
||||
runtime_handler.bind_action_context(workspace_context('workspace-b'))
|
||||
assert runtime_handler.bound_action_context is None
|
||||
|
||||
|
||||
def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace():
|
||||
def test_inbound_tenant_action_requires_complete_installation_envelope():
|
||||
runtime_handler, _app, installation_context = make_handler()
|
||||
|
||||
assert (
|
||||
@@ -220,35 +315,69 @@ def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace(
|
||||
)
|
||||
== installation_context
|
||||
)
|
||||
with pytest.raises(ValueError, match='does not match connection Workspace'):
|
||||
with pytest.raises(ValueError, match='complete InstallationBinding'):
|
||||
runtime_handler.validate_inbound_action_context(
|
||||
PluginToRuntimeAction.GET_BOTS.value,
|
||||
workspace_context('workspace-b').for_installation('installation-b'),
|
||||
)
|
||||
|
||||
|
||||
def test_installation_uuid_is_stable_and_workspace_specific():
|
||||
context_a = workspace_context('workspace-a')
|
||||
context_b = workspace_context('workspace-b')
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_oss_worker_capability_remains_usable_after_identity_migration():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
app.deployment = SimpleNamespace(mode='oss')
|
||||
setting = SimpleNamespace(
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
installation_uuid=installation_context.installation_uuid,
|
||||
runtime_revision=installation_context.runtime_revision,
|
||||
artifact_digest=installation_context.artifact_digest,
|
||||
)
|
||||
result = Mock()
|
||||
result.first.return_value = setting
|
||||
app.persistence_mgr.execute_async.return_value = result
|
||||
legacy_context = workspace_context().for_installation(installation_context.installation_uuid)
|
||||
|
||||
first = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_a,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
assert (
|
||||
runtime_handler.validate_inbound_action_context(
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION.value,
|
||||
legacy_context,
|
||||
)
|
||||
== legacy_context
|
||||
)
|
||||
repeated = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_a,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
)
|
||||
other_workspace = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_b,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
legacy_context,
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION,
|
||||
{},
|
||||
)
|
||||
|
||||
assert first == repeated
|
||||
assert first != other_workspace
|
||||
assert response.code == 0
|
||||
|
||||
|
||||
def test_installation_uuid_cannot_move_between_workspaces():
|
||||
runtime_handler, _app, binding = make_handler()
|
||||
moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2})
|
||||
|
||||
with pytest.raises(ValueError, match='cannot move between Workspaces'):
|
||||
runtime_handler.register_installation_binding(
|
||||
moved,
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newer_revision_fences_old_binding():
|
||||
runtime_handler, _app, binding = make_handler()
|
||||
newer = binding.model_copy(update={'runtime_revision': 2, 'artifact_digest': 'b' * 64})
|
||||
runtime_handler.register_installation_binding(
|
||||
newer,
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
)
|
||||
with pytest.raises(ValueError, match='revision or artifact is stale'):
|
||||
await runtime_handler._resolve_installation_identity(binding)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -281,16 +410,27 @@ async def test_plugin_vector_action_forwards_trusted_context_and_logical_collect
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_to_runtime_action_carries_trusted_connector_context():
|
||||
app = SimpleNamespace(logger=Mock())
|
||||
context = workspace_context()
|
||||
connection = RecordingConnection()
|
||||
runtime_handler = RuntimeConnectionHandler(
|
||||
connection,
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
context,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(runtime_handler.set_runtime_config(None))
|
||||
task = asyncio.create_task(
|
||||
runtime_handler.set_runtime_config(
|
||||
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='runtime-a'),
|
||||
worker_policy=PluginWorkerPolicy(
|
||||
max_cpus=1.0,
|
||||
max_memory_mb=256,
|
||||
max_pids=32,
|
||||
max_open_files=64,
|
||||
max_file_size_mb=128,
|
||||
),
|
||||
runtime_profile='oss_dev',
|
||||
cloud_service_url=None,
|
||||
)
|
||||
)
|
||||
for _ in range(10):
|
||||
if connection.sent:
|
||||
break
|
||||
@@ -299,5 +439,8 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
|
||||
runtime_handler.resp_waiters[request['seq_id']].set_result(ActionResponse.success({}))
|
||||
await task
|
||||
|
||||
assert request['data'] == {}
|
||||
assert request['context'] == context.model_dump(exclude_none=True)
|
||||
assert request['data']['runtime_identity'] == {
|
||||
'instance_uuid': 'instance-a',
|
||||
'runtime_id': 'runtime-a',
|
||||
}
|
||||
assert request.get('context') is None
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
"""Test plugin list filtering by component kinds."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
TEST_INSTALLATION_BINDING = InstallationBinding(
|
||||
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
|
||||
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
|
||||
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
|
||||
|
||||
def configure_connector(connector) -> None:
|
||||
connector._execution_context.set(TEST_EXECUTION_CONTEXT)
|
||||
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
|
||||
connector._load_workspace_settings = AsyncMock(return_value=[])
|
||||
connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -17,6 +44,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data with different component kinds
|
||||
mock_plugins = [
|
||||
@@ -90,7 +118,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
@@ -123,6 +151,7 @@ async def test_plugin_list_filter_no_filter():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data with different component kinds
|
||||
mock_plugins = [
|
||||
@@ -157,7 +186,7 @@ async def test_plugin_list_filter_no_filter():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
@@ -184,6 +213,7 @@ async def test_plugin_list_filter_empty_result():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data - only KnowledgeEngine plugins
|
||||
mock_plugins = [
|
||||
@@ -206,7 +236,7 @@ async def test_plugin_list_filter_empty_result():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
@@ -230,6 +260,7 @@ async def test_plugin_list_filter_plugin_without_components():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data - one with components, one without
|
||||
mock_plugins = [
|
||||
@@ -264,7 +295,7 @@ async def test_plugin_list_filter_plugin_without_components():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
"""Test plugin list sorting functionality."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
TEST_INSTALLATION_BINDING = InstallationBinding(
|
||||
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
|
||||
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
|
||||
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
|
||||
|
||||
def configure_connector(connector) -> None:
|
||||
connector._execution_context.set(TEST_EXECUTION_CONTEXT)
|
||||
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
|
||||
connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -18,6 +44,7 @@ async def test_plugin_list_sorting_debug_first():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data with different debug states and timestamps
|
||||
now = datetime.now()
|
||||
@@ -60,9 +87,7 @@ async def test_plugin_list_sorting_debug_first():
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
async def mock_load_workspace_settings(_execution_context):
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
@@ -85,12 +110,9 @@ async def test_plugin_list_sorting_debug_first():
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
return mock_rows
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
@@ -120,6 +142,7 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data - all non-debug with different installation times
|
||||
now = datetime.now()
|
||||
@@ -162,9 +185,7 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
async def mock_load_workspace_settings(_execution_context):
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
@@ -187,12 +208,9 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
return mock_rows
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
@@ -217,6 +235,7 @@ async def test_plugin_list_empty():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock empty plugin list
|
||||
connector.handler.list_plugins = AsyncMock(return_value=[])
|
||||
|
||||
Reference in New Issue
Block a user