mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 20:36:08 +00:00
feat(tenancy): implement workspace isolation
This commit is contained in:
@@ -14,6 +14,14 @@ from unittest.mock import Mock, AsyncMock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
|
||||
|
||||
TEST_ACTION_CONTEXT = ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
def get_connector_module():
|
||||
@@ -69,6 +77,7 @@ class TestListPlugins:
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
|
||||
)
|
||||
@@ -77,6 +86,8 @@ 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()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_by_component_kinds(self):
|
||||
@@ -85,6 +96,7 @@ class TestListPlugins:
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
@@ -112,6 +124,7 @@ class TestListPlugins:
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
|
||||
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def make_connector() -> PluginRuntimeConnector:
|
||||
@@ -30,3 +36,193 @@ async def test_ping_plugin_runtime_delegates_to_connected_handler():
|
||||
|
||||
assert result == 'pong'
|
||||
connector.handler.ping.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_connector_validates_workspace_without_runtime_handler():
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': False}}),
|
||||
workspace_service=SimpleNamespace(
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
request_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
|
||||
result = await connector.require_workspace_context(request_context)
|
||||
|
||||
assert result == request_context
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'workspace-a',
|
||||
expected_generation=3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_connector_reports_not_connected_after_workspace_validation():
|
||||
connector = make_connector()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
)
|
||||
)
|
||||
request_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginRuntimeNotConnectedError, match='Plugin runtime is not connected'):
|
||||
await connector.require_workspace_context(request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_resolves_oss_singleton_binding_from_workspace_service():
|
||||
connector = make_connector()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
get_local_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert await connector._resolve_action_context() == ActionContext(
|
||||
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():
|
||||
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,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
context = await connector._resolve_action_context()
|
||||
|
||||
assert context.workspace_uuid == 'workspace-a'
|
||||
|
||||
|
||||
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = make_connector()
|
||||
|
||||
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
|
||||
connector._control_headers(allow_generate=False)
|
||||
|
||||
|
||||
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = make_connector()
|
||||
|
||||
headers = connector._control_headers(allow_generate=True)
|
||||
|
||||
assert len(headers[PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER]) >= 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_fails_closed_without_trusted_workspace_service():
|
||||
connector = make_connector()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='Plugin Runtime Workspace binding is unavailable',
|
||||
):
|
||||
await connector._resolve_action_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'}
|
||||
get_local_binding = AsyncMock()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
get_local_execution_binding=get_local_binding,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='require an explicit projected Workspace binding',
|
||||
):
|
||||
await connector._resolve_action_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),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='require an explicit projected Workspace binding',
|
||||
):
|
||||
await connector.initialize()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_cloud_binding_is_revalidated_against_projection():
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'plugin': {'enable': True},
|
||||
'system': {'edition': 'cloud'},
|
||||
}
|
||||
)
|
||||
)
|
||||
configured = ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-cloud-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
app.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-cloud-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
),
|
||||
get_local_execution_binding=AsyncMock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock(), action_context=configured)
|
||||
|
||||
assert await connector._resolve_action_context() == 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()
|
||||
|
||||
@@ -10,13 +10,56 @@ 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
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
"""Create a RuntimeConnectionHandler with mocked external connection."""
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
|
||||
return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
|
||||
workspace_context = ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
app.workspace_service = SimpleNamespace(
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid=workspace_context.instance_uuid,
|
||||
workspace_uuid=workspace_context.workspace_uuid,
|
||||
placement_generation=workspace_context.placement_generation,
|
||||
)
|
||||
)
|
||||
)
|
||||
runtime_handler = RuntimeConnectionHandler(
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
workspace_context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
workspace_context,
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
)
|
||||
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
|
||||
query_pool = getattr(app, 'query_pool', None)
|
||||
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
|
||||
|
||||
def scoped_query(query):
|
||||
if query is not None:
|
||||
query.instance_uuid = workspace_context.instance_uuid
|
||||
query.workspace_uuid = workspace_context.workspace_uuid
|
||||
query.placement_generation = workspace_context.placement_generation
|
||||
return query
|
||||
|
||||
query_pool.get_query = AsyncMock(
|
||||
side_effect=lambda workspace_uuid, query_uuid: scoped_query(query_pool.cached_queries.get(query_uuid))
|
||||
)
|
||||
query_pool.get_query_by_legacy_id = AsyncMock(
|
||||
side_effect=lambda workspace_uuid, query_id: scoped_query(query_pool.cached_queries.get(query_id))
|
||||
)
|
||||
return runtime_handler
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
|
||||
@@ -8,13 +8,75 @@ 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.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.storage.mgr import StorageMgr
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
def canonical_binary_key(owner_type: str, owner: str, key: str) -> str:
|
||||
return StorageMgr.canonical_binary_storage_key(
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
owner_type=owner_type,
|
||||
owner=owner,
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
def make_handler(app, workspace_context: ActionContext | None = None):
|
||||
"""Create a RuntimeConnectionHandler with mocked external connection."""
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
|
||||
return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
|
||||
workspace_context = workspace_context or ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
app.workspace_service = SimpleNamespace(
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid=workspace_context.instance_uuid,
|
||||
workspace_uuid=workspace_context.workspace_uuid,
|
||||
placement_generation=workspace_context.placement_generation,
|
||||
)
|
||||
)
|
||||
)
|
||||
runtime_handler = RuntimeConnectionHandler(
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
workspace_context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
workspace_context,
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
)
|
||||
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
|
||||
query_pool = getattr(app, 'query_pool', None)
|
||||
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
|
||||
|
||||
def scoped_query(query):
|
||||
if query is not None:
|
||||
query.instance_uuid = workspace_context.instance_uuid
|
||||
query.workspace_uuid = workspace_context.workspace_uuid
|
||||
query.placement_generation = workspace_context.placement_generation
|
||||
return query
|
||||
|
||||
query_pool.get_query = AsyncMock(
|
||||
side_effect=lambda workspace_uuid, query_uuid: scoped_query(query_pool.cached_queries.get(query_uuid))
|
||||
)
|
||||
query_pool.get_query_by_legacy_id = AsyncMock(
|
||||
side_effect=lambda workspace_uuid, query_id: scoped_query(query_pool.cached_queries.get(query_id))
|
||||
)
|
||||
return runtime_handler
|
||||
|
||||
|
||||
def make_result(first_item=None):
|
||||
@@ -34,6 +96,8 @@ class TestRagRerankAction:
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.model_mgr = Mock()
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result(SimpleNamespace(uuid='rerank-1')))
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
|
||||
@@ -63,12 +127,16 @@ class TestRagRerankAction:
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}]
|
||||
app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with('rerank-1')
|
||||
app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with(
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
'rerank-1',
|
||||
)
|
||||
provider.invoke_rerank.assert_awaited_once_with(
|
||||
model=rerank_model,
|
||||
query='hello',
|
||||
documents=['a', 'b'],
|
||||
extra_args={'return_documents': False},
|
||||
execution_context=TEST_EXECUTION_CONTEXT,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -122,6 +190,7 @@ class TestInitializePluginSettings:
|
||||
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',
|
||||
@@ -218,7 +287,12 @@ class TestSetBinaryStorage:
|
||||
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['unique_key'] == 'plugin:test-owner:test-key'
|
||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||
assert insert_params['unique_key'] == canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'test-key',
|
||||
)
|
||||
assert insert_params['value'] == b'x' * 512
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -231,7 +305,15 @@ class TestSetBinaryStorage:
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
select_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[0].args[0])
|
||||
update_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
expected_key = canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'test-key',
|
||||
)
|
||||
assert expected_key in select_params.values()
|
||||
assert expected_key in update_params.values()
|
||||
assert update_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -304,6 +386,11 @@ class TestGetPluginSettings:
|
||||
'plugin_config': {},
|
||||
'install_source': 'local',
|
||||
'install_info': {},
|
||||
'installation_uuid': runtime_handler.derive_installation_uuid(
|
||||
runtime_handler.require_bound_action_context(),
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -333,9 +420,90 @@ 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',
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TestGetConfigFile:
|
||||
"""Plugin config files remain bound to the trusted runtime placement."""
|
||||
|
||||
WORKSPACE_A = '11111111-1111-4111-8111-111111111111'
|
||||
WORKSPACE_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.storage_mgr = StorageMgr(mock_app)
|
||||
mock_app.storage_mgr.storage_provider = SimpleNamespace(load=AsyncMock(return_value=b'plugin config bytes'))
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
|
||||
@staticmethod
|
||||
def object_key(
|
||||
*,
|
||||
workspace_uuid: str = WORKSPACE_A,
|
||||
placement_generation: int = 1,
|
||||
owner_type: str = 'plugin_config',
|
||||
) -> str:
|
||||
return StorageMgr.scoped_object_key(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=placement_generation,
|
||||
),
|
||||
owner_type=owner_type,
|
||||
owner=workspace_uuid,
|
||||
key='config.json',
|
||||
)
|
||||
|
||||
async def invoke(self, app, file_key: str):
|
||||
runtime_handler = make_handler(
|
||||
app,
|
||||
ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=self.WORKSPACE_A,
|
||||
placement_generation=1,
|
||||
),
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(
|
||||
SimpleNamespace(config={'uploaded_file': file_key})
|
||||
)
|
||||
return await runtime_handler.actions[PluginToRuntimeAction.GET_CONFIG_FILE.value]({'file_key': file_key})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loads_config_file_from_same_workspace_generation(self, app):
|
||||
file_key = self.object_key()
|
||||
|
||||
response = await self.invoke(app, file_key)
|
||||
|
||||
assert response.code == 0
|
||||
assert base64.b64decode(response.data['file_base64']) == b'plugin config bytes'
|
||||
app.storage_mgr.storage_provider.load.assert_awaited_once_with(file_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'file_key',
|
||||
[
|
||||
object_key.__func__(workspace_uuid=WORKSPACE_B),
|
||||
object_key.__func__(placement_generation=2),
|
||||
object_key.__func__(owner_type='upload_document'),
|
||||
],
|
||||
ids=['other-workspace', 'stale-generation', 'wrong-owner-type'],
|
||||
)
|
||||
async def test_rejects_config_file_outside_trusted_scope(self, app, file_key):
|
||||
response = await self.invoke(app, file_key)
|
||||
|
||||
assert response.code != 0
|
||||
assert 'Failed to load config file' in response.message
|
||||
app.storage_mgr.storage_provider.load.assert_not_awaited()
|
||||
|
||||
|
||||
class TestGetBinaryStorage:
|
||||
"""Tests for get_binary_storage action handler."""
|
||||
|
||||
@@ -364,6 +532,16 @@ class TestGetBinaryStorage:
|
||||
assert response.data == {
|
||||
'value_base64': base64.b64encode(b'test binary content').decode('utf-8'),
|
||||
}
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert 'workspace-a' in statement_params.values()
|
||||
assert (
|
||||
canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'test-key',
|
||||
)
|
||||
in statement_params.values()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_when_not_found(self, app):
|
||||
@@ -383,6 +561,63 @@ class TestGetBinaryStorage:
|
||||
assert 'Storage with key test-key not found' in response.message
|
||||
|
||||
|
||||
class TestDeleteAndListBinaryStorage:
|
||||
"""Delete/list remain fenced to the trusted canonical owner scope."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_uses_workspace_and_canonical_unique_key(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
||||
{
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'forged-owner',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert 'workspace-a' in statement_params.values()
|
||||
assert (
|
||||
canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'test-key',
|
||||
)
|
||||
in statement_params.values()
|
||||
)
|
||||
assert 'forged-owner' not in statement_params.values()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||
result = Mock()
|
||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
||||
app.persistence_mgr.execute_async.return_value = result
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS.value](
|
||||
{
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'forged-owner',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {'keys': ['first', 'second']}
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert 'workspace-a' in statement_params.values()
|
||||
assert 'test-author/test-plugin' in statement_params.values()
|
||||
assert 'forged-owner' not in statement_params.values()
|
||||
|
||||
|
||||
class TestHandlerQueryLookup:
|
||||
"""Tests for query lookup in cached_queries."""
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, 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.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
|
||||
|
||||
|
||||
class EmptyResult:
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class RecordingConnection(Connection):
|
||||
def __init__(self):
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
self.sent.append(message)
|
||||
|
||||
async def receive(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def workspace_context(workspace_uuid: str = 'workspace-a') -> ActionContext:
|
||||
return ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
|
||||
def make_handler(workspace_uuid: str = 'workspace-a'):
|
||||
context = workspace_context(workspace_uuid)
|
||||
app = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=EmptyResult())),
|
||||
logger=Mock(),
|
||||
workspace_service=SimpleNamespace(
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid=context.instance_uuid,
|
||||
workspace_uuid=context.workspace_uuid,
|
||||
placement_generation=context.placement_generation,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
runtime_handler = RuntimeConnectionHandler(
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
context,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
)
|
||||
return runtime_handler, app, context.for_installation(installation_uuid)
|
||||
|
||||
|
||||
async def invoke_with_context(
|
||||
runtime_handler: RuntimeConnectionHandler,
|
||||
action_context: ActionContext,
|
||||
action: PluginToRuntimeAction,
|
||||
data: dict,
|
||||
):
|
||||
token = runtime_handler._current_action_context.set(action_context)
|
||||
try:
|
||||
return await runtime_handler.actions[action.value](data)
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_action_requires_installation_capability():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
|
||||
with pytest.raises(ValueError, match='missing installation_uuid'):
|
||||
await runtime_handler.actions[PluginToRuntimeAction.GET_LANGBOT_VERSION.value]({})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_action_rejects_forged_installation_capability():
|
||||
runtime_handler, _app, installation_context = make_handler()
|
||||
forged = installation_context.model_copy(update={'installation_uuid': 'forged-installation'})
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='installation is not registered in this Workspace',
|
||||
):
|
||||
await invoke_with_context(
|
||||
runtime_handler,
|
||||
forged,
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION,
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_action_rejects_stale_workspace_generation():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
app.workspace_service.get_execution_binding.side_effect = ValueError('generation is fenced')
|
||||
|
||||
with pytest.raises(ValueError, match='generation is fenced'):
|
||||
await invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION,
|
||||
{},
|
||||
)
|
||||
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'workspace-a',
|
||||
expected_generation=7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_uuid_and_forged_payload_workspace_cannot_cross_tenants():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
query_a = SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
bot_uuid='bot-a',
|
||||
)
|
||||
query_b = SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-b',
|
||||
placement_generation=7,
|
||||
bot_uuid='bot-b',
|
||||
)
|
||||
|
||||
async def get_query(workspace_uuid, query_uuid):
|
||||
return {
|
||||
('workspace-a', 'query-a'): query_a,
|
||||
('workspace-b', 'query-b'): query_b,
|
||||
}.get((workspace_uuid, query_uuid))
|
||||
|
||||
app.query_pool = SimpleNamespace(
|
||||
get_query=AsyncMock(side_effect=get_query),
|
||||
get_query_by_legacy_id=AsyncMock(),
|
||||
)
|
||||
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.GET_BOT_UUID,
|
||||
{
|
||||
'query_id': 2,
|
||||
'query_uuid': 'query-b',
|
||||
'workspace_uuid': 'workspace-b',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.code != 0
|
||||
app.query_pool.get_query.assert_awaited_once_with('workspace-a', 'query-b')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_query_id_fallback_is_workspace_scoped():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
query = SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
bot_uuid='bot-a',
|
||||
)
|
||||
app.query_pool = SimpleNamespace(
|
||||
get_query=AsyncMock(),
|
||||
get_query_by_legacy_id=AsyncMock(return_value=query),
|
||||
)
|
||||
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.GET_BOT_UUID,
|
||||
{'query_id': 19},
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {'bot_uuid': 'bot-a'}
|
||||
app.query_pool.get_query_by_legacy_id.assert_awaited_once_with(
|
||||
'workspace-a',
|
||||
19,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_connection_rejects_workspace_rebinding():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
|
||||
with pytest.raises(ValueError, match='another Workspace'):
|
||||
runtime_handler.bind_action_context(workspace_context('workspace-b'))
|
||||
|
||||
|
||||
def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace():
|
||||
runtime_handler, _app, installation_context = make_handler()
|
||||
|
||||
assert (
|
||||
runtime_handler.validate_inbound_action_context(
|
||||
PluginToRuntimeAction.GET_BOTS.value,
|
||||
installation_context,
|
||||
)
|
||||
== installation_context
|
||||
)
|
||||
with pytest.raises(ValueError, match='does not match connection Workspace'):
|
||||
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')
|
||||
|
||||
first = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_a,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
)
|
||||
repeated = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_a,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
)
|
||||
other_workspace = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_b,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
)
|
||||
|
||||
assert first == repeated
|
||||
assert first != other_workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_vector_action_forwards_trusted_context_and_logical_collection():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
app.rag_runtime_service = SimpleNamespace(vector_upsert=AsyncMock())
|
||||
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.VECTOR_UPSERT,
|
||||
{
|
||||
'workspace_uuid': 'workspace-forged',
|
||||
'collection_id': 'plugin-supplied-name',
|
||||
'vectors': [[0.1]],
|
||||
'ids': ['point-a'],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
execution_context = app.rag_runtime_service.vector_upsert.await_args.args[0]
|
||||
assert execution_context == ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
assert app.rag_runtime_service.vector_upsert.await_args.args[1] == 'plugin-supplied-name'
|
||||
|
||||
|
||||
@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))
|
||||
for _ in range(10):
|
||||
if connection.sent:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
request = json.loads(connection.sent[0])
|
||||
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)
|
||||
Reference in New Issue
Block a user