Files
LangBot/tests/unit_tests/plugin/test_handler_actions.py
T
RockChinQ e1ac5e0fc8 feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture

* Add OSS and commercial workspace boundaries

* docs: redesign multi-tenant workspace architecture

* feat(tenancy): implement workspace isolation

* docs(tenancy): record verification evidence

* docs(tenancy): revise single-instance SaaS topology

* docs(tenancy): refine architecture options

* docs: finalize cloud v2 multi-tenant decisions

* feat(tenancy): establish cloud isolation foundations

* feat(tenancy): harden shared cloud runtime boundaries

* docs(tenancy): record final isolation verification

* fix(tenancy): close isolation and permission gaps

* docs(tenancy): record final isolation verification

* feat(tenancy): connect cloud workspace control plane

* fix(build): install git for pinned SDK

* docs(cloud): update control plane verification

* chore: update multi-tenant SDK pin

* fix(cloud): skip legacy model sync during startup

* test(cloud): preserve minimal model manager fixtures

* fix(cloud): preserve authenticated account context

* fix(cloud): reuse authenticated account for user info

* feat(cloud): complete Workspace settings navigation

* test(web): cover Workspace dropdown menu

* feat(web): place workspace controls in sidebar

* refactor(web): streamline workspace controls

* style(web): format workspace layout test

* fix(cloud): surface runtime and workspace plan status

* fix(plugin): keep runtime identity stable across restarts

* fix(ui): widen and center workspace switcher

* fix(ui): hide roles from workspace switcher

* fix(ui): align workspace switcher with sidebar entries

* feat(workspace): add in-product collaboration and direct Cloud launch

* style: format collaboration changes

* fix(workspace): bind collaboration APIs to tenant UoW

* fix(cloud): preserve Core-owned collaboration state

* test(cloud): require Space identity for invite registration

* feat(cloud): complete secure invitation experience

* style(web): format invitation flows

* fix(cloud): recover box runtime without unscoped skill reload

* feat(oss): enforce invitation account and owner billing flows

* style: format OSS account service

* test(oss): cover invitation logout handoff

* fix(oss): resolve workspace owner in scoped session

* feat(cloud): harden multi-tenant runtime resources

* fix(cloud): bound runtime restart storms

* fix(cloud): eliminate periodic runtime CPU spikes

* fix(cloud): enforce instance capacity ceilings

* fix(cloud): scope public login capability discovery

* fix(cloud): bound tenant maintenance and monitoring work

* fix(runtime): bound tenant resource amplification

* fix(deps): pin green multi-tenant plugin SDK

* fix(cloud): handle unavailable skill capability

* fix(security): require authentication for image file endpoint (H-2)

- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations

* test: add comprehensive cross-tenant isolation tests

Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide

* fix(security): resolve M-1, M-2, M-3 security findings

M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.

* fix(cloud): unblock tenant CI and enforce knowledge quotas

* fix(tenancy): scope rerank model sync

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-30 21:43:35 +08:00

643 lines
23 KiB
Python

"""Unit tests for RuntimeConnectionHandler action handlers."""
from __future__ import annotations
import base64
from types import SimpleNamespace
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, InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.storage.mgr import StorageMgr
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
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,
)
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.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'):
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):
result = Mock()
result.first = Mock(return_value=first_item)
return result
def compiled_params(statement):
return statement.compile().params
class TestRagRerankAction:
"""Tests for RAG rerank action handler."""
@pytest.fixture
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
@pytest.mark.asyncio
async def test_invokes_rerank_model_and_sorts_scores(self, app):
"""Rerank action uses the selected model and returns top scores."""
provider = Mock()
provider.invoke_rerank = AsyncMock(
return_value=[
{'index': 0, 'relevance_score': 0.2},
{'index': 1, 'relevance_score': 0.9},
]
)
rerank_model = SimpleNamespace(provider=provider)
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=rerank_model)
runtime_handler = make_handler(app)
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
{
'rerank_model_uuid': 'rerank-1',
'query': 'hello',
'documents': ['a', 'b'],
'top_k': 1,
'extra_args': {'return_documents': False},
}
)
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(
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
async def test_returns_error_when_rerank_model_missing(self, app):
"""Missing rerank model returns an action error."""
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=ValueError('not found'))
runtime_handler = make_handler(app)
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
{
'rerank_model_uuid': 'missing',
'query': 'hello',
'documents': ['a'],
}
)
assert response.code != 0
assert 'Rerank model with rerank_model_uuid missing not found' in response.message
class TestInitializePluginSettings:
"""Tests for initialize_plugin_settings action handler."""
@pytest.fixture
def app(self):
mock_app = Mock()
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.logger = Mock()
return mock_app
@pytest.mark.asyncio
async def test_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.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
'install_source': 'local',
'install_info': {'path': '/test'},
}
)
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_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.return_value = make_result(existing_setting)
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
'install_source': 'github',
'install_info': {'repo': 'author/name'},
}
)
assert response.code == 0
app.persistence_mgr.execute_async.assert_awaited_once()
class TestSetBinaryStorage:
"""Tests for set_binary_storage action handler with size limit validation."""
@pytest.fixture
def app(self):
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'plugin': {
'binary_storage': {
'max_value_bytes': 1024,
},
},
}
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
mock_app.logger = Mock()
return mock_app
@staticmethod
def payload(value: bytes):
return {
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'test-owner',
'value_base64': base64.b64encode(value).decode('utf-8'),
}
@pytest.mark.asyncio
async def test_rejects_value_exceeding_limit(self, app):
"""Values larger than max_value_bytes are rejected before persistence writes."""
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 2048)
)
assert response.code != 0
assert '1024-byte limit' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_accepts_value_within_limit_and_inserts_storage(self, app):
"""A new small value is inserted into binary storage."""
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 512)
)
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'
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
async def test_updates_existing_storage(self, app):
"""An existing binary storage row is updated instead of inserted."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'old'))
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
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
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
"""Invalid max_value_bytes uses the 10MB default limit."""
runtime_handler = make_handler(app)
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid'
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * (10 * 1024 * 1024 + 1))
)
assert response.code != 0
assert '10485760' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_negative_limit_falls_back_to_bounded_default(self, app, monkeypatch):
"""Negative max_value_bytes cannot disable the process memory boundary."""
import langbot.pkg.plugin.handler as handler_module
runtime_handler = make_handler(app)
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1
monkeypatch.setattr(handler_module, '_DEFAULT_BINARY_STORAGE_VALUE_BYTES', 1024)
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 2048)
)
assert response.code != 0
assert '1024-byte limit' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_zero_limit_rejects_non_empty_values(self, app):
"""A zero byte limit rejects non-empty values."""
runtime_handler = make_handler(app)
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'x'))
assert response.code != 0
assert '1 > 0 bytes' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
class TestGetPluginSettings:
"""Tests for get_plugin_settings action handler with defaults."""
@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_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()
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):
"""Persisted plugin setting values override defaults."""
runtime_handler = make_handler(app)
setting = SimpleNamespace(
enabled=False,
priority=10,
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)
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': False,
'priority': 10,
'plugin_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,
}
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_bounded=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_bounded.assert_awaited_once_with(
file_key,
max_bytes=10 * 1024 * 1024,
)
@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_bounded.assert_not_awaited()
class TestGetBinaryStorage:
"""Tests for get_binary_storage action handler."""
@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_returns_base64_encoded_value(self, app):
"""Stored bytes are returned as base64."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'test binary content'))
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'test-owner',
}
)
assert response.code == 0
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):
"""Missing binary storage rows return an error response."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'test-owner',
}
)
assert response.code != 0
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."""
@pytest.fixture
def app(self):
mock_app = Mock()
mock_app.query_pool = Mock()
mock_app.query_pool.cached_queries = {}
mock_app.logger = Mock()
return mock_app
@pytest.mark.asyncio
async def test_query_not_found_returns_error(self, app):
"""Query-bound actions return error when query_id is not cached."""
runtime_handler = make_handler(app)
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value](
{
'query_id': 'nonexistent-query',
}
)
assert response.code != 0
assert 'nonexistent-query' in response.message
@pytest.mark.asyncio
async def test_query_found_returns_success(self, app):
"""Query-bound actions read data from the cached query object."""
runtime_handler = make_handler(app)
query = SimpleNamespace(variables={}, bot_uuid='test-bot-uuid')
app.query_pool.cached_queries['existing-query'] = query
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value](
{
'query_id': 'existing-query',
}
)
assert response.code == 0
assert response.data == {'bot_uuid': 'test-bot-uuid'}