mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 20:30:59 +00:00
feat(cloud): harden multi-tenant runtime resources
This commit is contained in:
@@ -68,6 +68,25 @@ async def test_connection_listener_only_suppresses_exact_duplicates():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_event_cache_is_bounded():
|
||||
adapter, _ = _make_adapter()
|
||||
|
||||
for index in range(150):
|
||||
await adapter._on_websocket_connection(aiocqhttp.Event({'self_id': index, 'time': index}))
|
||||
|
||||
assert len(adapter.on_websocket_connection_event_cache) == 100
|
||||
|
||||
|
||||
def test_group_lookup_caches_are_bounded():
|
||||
converter = AiocqhttpEventConverter()
|
||||
converter._group_name_cache = {index: (str(index), 10_000.0) for index in range(5000)}
|
||||
|
||||
converter._prune_caches(1.0)
|
||||
|
||||
assert len(converter._group_name_cache) == 4096
|
||||
|
||||
|
||||
def test_unregister_listener_removes_registered_wrapper():
|
||||
adapter, _ = _make_adapter()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -8,7 +9,10 @@ import pytest
|
||||
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.entity.persistence.bot import Bot
|
||||
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
|
||||
from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
|
||||
from langbot.pkg.workspace.errors import WorkspaceInvariantError
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
@@ -83,7 +87,220 @@ async def test_public_route_key_resolves_bound_runtime_and_rejects_non_opaque_in
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_runtime_generation_is_not_returned(manager):
|
||||
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A) is None
|
||||
with pytest.raises(ValueError, match='stale'):
|
||||
await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generation_advance_shuts_down_and_prunes_old_workspace_bots():
|
||||
class NoGlobalIterationDict(dict):
|
||||
def __iter__(self):
|
||||
raise AssertionError('generation advance scanned every bot runtime')
|
||||
|
||||
def items(self):
|
||||
raise AssertionError('generation advance scanned every bot runtime')
|
||||
|
||||
def values(self):
|
||||
raise AssertionError('generation advance scanned every bot runtime')
|
||||
|
||||
manager = PlatformManager(SimpleNamespace())
|
||||
old_bot = SimpleNamespace(
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
placement_generation=4,
|
||||
enable=True,
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
other_bot = SimpleNamespace(
|
||||
workspace_uuid=WORKSPACE_B,
|
||||
placement_generation=4,
|
||||
enable=True,
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
unrelated_bots = [
|
||||
SimpleNamespace(
|
||||
workspace_uuid=f'workspace-{index}',
|
||||
placement_generation=4,
|
||||
enable=False,
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
for index in range(1_000)
|
||||
]
|
||||
manager.bots = [old_bot, other_bot, *unrelated_bots]
|
||||
old_context = _context(WORKSPACE_A, BOT_A, generation=4)
|
||||
next_context = _context(WORKSPACE_A, BOT_A, generation=5)
|
||||
|
||||
await manager._observe_execution_context(old_context)
|
||||
manager._bots_by_key = NoGlobalIterationDict(manager._bots_by_key)
|
||||
await manager._observe_execution_context(next_context)
|
||||
manager._bots_by_key = dict(manager._bots_by_key)
|
||||
|
||||
old_bot.shutdown.assert_awaited_once_with()
|
||||
assert manager.bots == [other_bot, *unrelated_bots]
|
||||
with pytest.raises(WorkspaceInvariantError, match='rolled back'):
|
||||
await manager._observe_execution_context(old_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_websocket_proxy_creation_reuses_one_runtime():
|
||||
created_adapters = []
|
||||
|
||||
class WebsocketAdapter:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
created_adapters.append(self)
|
||||
|
||||
def register_listener(self, *_args):
|
||||
pass
|
||||
|
||||
application = SimpleNamespace(workspace_service=_WorkspaceService())
|
||||
manager = PlatformManager(application)
|
||||
manager.adapter_dict = {'websocket': WebsocketAdapter}
|
||||
context = ExecutionContext(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
placement_generation=4,
|
||||
)
|
||||
|
||||
runtimes = await asyncio.gather(*(manager.get_websocket_proxy_bot(context) for _ in range(20)))
|
||||
|
||||
assert len(created_adapters) == 1
|
||||
assert len({id(runtime) for runtime in runtimes}) == 1
|
||||
assert manager.websocket_proxy_bots == {WORKSPACE_A: runtimes[0]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_proxy_cache_evicts_oldest_idle_workspace():
|
||||
created_adapters = []
|
||||
|
||||
class WebsocketAdapter:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
self.kill = AsyncMock()
|
||||
self.inbound_listener_tasks = set()
|
||||
created_adapters.append(self)
|
||||
|
||||
def register_listener(self, *_args):
|
||||
pass
|
||||
|
||||
application = SimpleNamespace(
|
||||
workspace_service=_WorkspaceService(),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'system': {
|
||||
'websocket_retention': {'max_workspace_proxies': 1},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
manager = PlatformManager(application)
|
||||
manager.adapter_dict = {'websocket': WebsocketAdapter}
|
||||
|
||||
await manager.get_websocket_proxy_bot(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
placement_generation=4,
|
||||
)
|
||||
)
|
||||
second = await manager.get_websocket_proxy_bot(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid=WORKSPACE_B,
|
||||
placement_generation=4,
|
||||
)
|
||||
)
|
||||
|
||||
created_adapters[0].kill.assert_awaited_once_with()
|
||||
assert manager.websocket_proxy_bots == {WORKSPACE_B: second}
|
||||
assert WORKSPACE_A not in manager._proxy_last_accessed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_stops_and_drops_existing_platform_runtimes():
|
||||
old_bot = SimpleNamespace(enable=True, shutdown=AsyncMock())
|
||||
old_proxy = SimpleNamespace(enable=True, shutdown=AsyncMock())
|
||||
persistence_mgr = SimpleNamespace(
|
||||
execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [])),
|
||||
)
|
||||
application = SimpleNamespace(
|
||||
logger=SimpleNamespace(info=lambda *_args: None, warning=lambda *_args: None),
|
||||
persistence_mgr=persistence_mgr,
|
||||
workspace_service=SimpleNamespace(),
|
||||
)
|
||||
manager = PlatformManager(application)
|
||||
manager.bots = [old_bot]
|
||||
manager.websocket_proxy_bots = {WORKSPACE_A: old_proxy}
|
||||
manager._scope_generations = {('instance', WORKSPACE_A): 4}
|
||||
|
||||
await manager.load_bots_from_db()
|
||||
|
||||
old_bot.shutdown.assert_awaited_once_with()
|
||||
old_proxy.shutdown.assert_awaited_once_with()
|
||||
assert manager.bots == []
|
||||
assert manager.websocket_proxy_bots == {}
|
||||
assert manager._scope_generations == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_startup_reuses_validated_platform_binding():
|
||||
class TenantUow:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
class ProbeAdapter:
|
||||
def __init__(self, _config, _logger):
|
||||
self.listeners = []
|
||||
|
||||
def register_listener(self, event_type, listener):
|
||||
self.listeners.append((event_type, listener))
|
||||
|
||||
async def kill(self):
|
||||
return None
|
||||
|
||||
binding = WorkspaceExecutionBinding(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
placement_generation=4,
|
||||
write_fenced=False,
|
||||
state='active',
|
||||
)
|
||||
bot = Bot(
|
||||
uuid=BOT_A,
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
name='Probe',
|
||||
description='',
|
||||
adapter='probe',
|
||||
adapter_config={},
|
||||
enable=False,
|
||||
pipeline_routing_rules=[],
|
||||
)
|
||||
workspace_service = SimpleNamespace(
|
||||
list_active_execution_bindings=AsyncMock(return_value=[binding]),
|
||||
get_execution_binding=AsyncMock(
|
||||
side_effect=AssertionError('startup platform loader repeated a validated binding lookup')
|
||||
),
|
||||
)
|
||||
application = SimpleNamespace(
|
||||
logger=SimpleNamespace(
|
||||
info=lambda *_args, **_kwargs: None,
|
||||
warning=lambda *_args, **_kwargs: None,
|
||||
error=lambda *_args, **_kwargs: None,
|
||||
),
|
||||
persistence_mgr=SimpleNamespace(
|
||||
mode=SimpleNamespace(value='cloud_runtime'),
|
||||
tenant_uow=lambda _workspace_uuid: TenantUow(),
|
||||
execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [bot])),
|
||||
),
|
||||
workspace_service=workspace_service,
|
||||
)
|
||||
manager = PlatformManager(application)
|
||||
manager.adapter_dict = {'probe': ProbeAdapter}
|
||||
|
||||
await manager.load_bots_from_db()
|
||||
|
||||
assert len(manager.bots) == 1
|
||||
workspace_service.get_execution_binding.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||
from langbot.pkg.platform.sources.dingtalk import (
|
||||
DingTalkAdapter,
|
||||
_dingtalk_card_markdown,
|
||||
@@ -17,6 +18,17 @@ from langbot.pkg.platform.sources.dingtalk import (
|
||||
)
|
||||
|
||||
|
||||
def test_dingtalk_auxiliary_tasks_are_bounded():
|
||||
adapter = DingTalkAdapter.model_construct()
|
||||
adapter._background_tasks = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
|
||||
|
||||
async def callback():
|
||||
raise AssertionError('rejected callback must not run')
|
||||
|
||||
assert adapter._start_background_task(callback()) is False
|
||||
assert len(adapter._background_tasks) == 100
|
||||
|
||||
|
||||
def test_dingtalk_select_component_params_expose_options():
|
||||
params = _dingtalk_form_component_params(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources import discord
|
||||
|
||||
|
||||
def test_discord_base64_decode_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(discord, '_MAX_DISCORD_MEDIA_BYTES', 4)
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
discord._decode_discord_base64_limited('A' * 12)
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
|
||||
from langbot.pkg.platform.sources import http_bot as http_bot_module
|
||||
|
||||
|
||||
def _session(key):
|
||||
@@ -22,6 +24,7 @@ def _adapter(app, execution_context) -> HttpBotAdapter:
|
||||
outbound_states={},
|
||||
idempotency_cache={},
|
||||
sync_waiters={},
|
||||
inbound_tasks=set(),
|
||||
)
|
||||
object.__setattr__(adapter, 'ap', app)
|
||||
return adapter
|
||||
@@ -64,3 +67,28 @@ async def test_http_bot_reset_fails_closed_without_trusted_scope():
|
||||
|
||||
with pytest.raises(RuntimeError, match='trusted execution scope'):
|
||||
await adapter._reset_session('person', 'shared-session')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_bot_bounds_inbound_listener_tasks(monkeypatch):
|
||||
monkeypatch.setattr(http_bot_module, '_INBOUND_TASK_MAX', 1)
|
||||
adapter = _adapter(SimpleNamespace(), None)
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def blocking_listener():
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
first = adapter._start_inbound_task(blocking_listener())
|
||||
await started.wait()
|
||||
rejected = adapter._start_inbound_task(blocking_listener())
|
||||
|
||||
assert first is not None
|
||||
assert rejected is None
|
||||
assert len(adapter.inbound_tasks) == 1
|
||||
|
||||
release.set()
|
||||
await first
|
||||
await asyncio.sleep(0)
|
||||
assert adapter.inbound_tasks == set()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources import kook
|
||||
|
||||
|
||||
def test_kook_gateway_decoder_accepts_raw_and_compressed_json():
|
||||
payload = {'s': 1, 'd': {'session_id': 'session-a'}}
|
||||
encoded = json.dumps(payload).encode()
|
||||
|
||||
assert kook._decode_gateway_message(encoded) == payload
|
||||
assert kook._decode_gateway_message(zlib.compress(encoded)) == payload
|
||||
|
||||
|
||||
def test_kook_gateway_decoder_rejects_decompression_bomb(monkeypatch):
|
||||
monkeypatch.setattr(kook, '_KOOK_MAX_GATEWAY_MESSAGE_BYTES', 1024)
|
||||
compressed = zlib.compress(b'x' * 1025)
|
||||
|
||||
with pytest.raises(ValueError, match='decompressed size limit'):
|
||||
kook._decode_gateway_message(compressed)
|
||||
@@ -1,7 +1,13 @@
|
||||
"""Tests for Lark adapter helper behavior."""
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources.lark import (
|
||||
LarkAdapter,
|
||||
_decode_lark_base64_limited,
|
||||
_lark_clean_form_content,
|
||||
_lark_completed_input_lines,
|
||||
_lark_current_input_defs,
|
||||
@@ -11,6 +17,27 @@ from langbot.pkg.platform.sources.lark import (
|
||||
)
|
||||
|
||||
|
||||
def test_lark_base64_decode_is_bounded(monkeypatch):
|
||||
import langbot.pkg.platform.sources.lark as lark_module
|
||||
|
||||
monkeypatch.setattr(lark_module, '_MAX_LARK_MEDIA_BYTES', 4)
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
_decode_lark_base64_limited('A' * 12)
|
||||
|
||||
|
||||
def test_lark_threadsafe_callbacks_are_bounded():
|
||||
adapter = LarkAdapter.model_construct()
|
||||
adapter.threadsafe_event_lock = threading.Lock()
|
||||
adapter.threadsafe_event_futures = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
|
||||
|
||||
async def callback():
|
||||
raise AssertionError('rejected callback must not run')
|
||||
|
||||
assert adapter._schedule_threadsafe_event(callback()) is None
|
||||
assert len(adapter.threadsafe_event_futures) == 100
|
||||
|
||||
|
||||
def test_lark_current_input_defs_only_returns_active_stage():
|
||||
input_defs = [
|
||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||
from langbot.pkg.platform.sources import line
|
||||
|
||||
|
||||
def test_line_media_content_accepts_limit_boundary(monkeypatch) -> None:
|
||||
monkeypatch.setattr(line, 'MAX_LINE_MEDIA_BYTES', 4)
|
||||
content = b'1234'
|
||||
|
||||
assert line._validate_line_media_content(content) is content
|
||||
|
||||
|
||||
def test_line_media_content_rejects_oversized_payload(monkeypatch) -> None:
|
||||
monkeypatch.setattr(line, 'MAX_LINE_MEDIA_BYTES', 4)
|
||||
|
||||
with pytest.raises(ValueError, match='LINE media exceeds'):
|
||||
line._validate_line_media_content(b'12345')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_line_kill_closes_api_client() -> None:
|
||||
api_client = MagicMock()
|
||||
adapter = line.LINEAdapter.model_construct(api_client=api_client)
|
||||
|
||||
assert await adapter.kill() is True
|
||||
api_client.close.assert_called_once_with()
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources import matrix
|
||||
|
||||
|
||||
def test_matrix_base64_decode_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(matrix, '_MAX_MATRIX_MEDIA_BYTES', 4)
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
matrix._decode_matrix_base64_limited('A' * 12)
|
||||
|
||||
|
||||
def test_matrix_local_file_read_is_bounded(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(matrix, '_MAX_MATRIX_MEDIA_BYTES', 4)
|
||||
path = tmp_path / 'large.bin'
|
||||
path.write_bytes(b'12345')
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
matrix._read_matrix_file_limited(str(path))
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.libs.openclaw_weixin_api.client import (
|
||||
MAX_CDN_MEDIA_BYTES,
|
||||
OpenClawWeixinClient,
|
||||
_decrypt_cdn_payload,
|
||||
_encrypt_cdn_payload,
|
||||
)
|
||||
from langbot.libs.openclaw_weixin_api.types import ApiError
|
||||
|
||||
|
||||
def test_cdn_crypto_helpers_round_trip():
|
||||
original = b'tenant-media' * 128
|
||||
|
||||
aes_key_hex, _encoded_key, encrypted, _raw_md5 = _encrypt_cdn_payload(original)
|
||||
|
||||
assert _decrypt_cdn_payload(encrypted, bytes.fromhex(aes_key_hex)) == original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_media_rejects_oversized_input_before_network_access():
|
||||
client = OpenClawWeixinClient('https://example.invalid', 'token')
|
||||
|
||||
with pytest.raises(ApiError, match='exceeds the size limit'):
|
||||
await client.upload_media(
|
||||
b'x' * (MAX_CDN_MEDIA_BYTES + 1),
|
||||
'recipient',
|
||||
3,
|
||||
)
|
||||
@@ -2,8 +2,10 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.platform.sources import openclaw_weixin
|
||||
from langbot.pkg.platform.sources.openclaw_weixin import OpenClawWeixinAdapter
|
||||
|
||||
|
||||
@@ -82,3 +84,12 @@ async def test_persist_config_fails_closed_without_matching_execution_context(ex
|
||||
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
logger.warning.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_base64_decode_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(openclaw_weixin, '_MAX_OPENCLAW_COMPONENT_BYTES', 4)
|
||||
component = platform_message.File(base64='MTIzNDU=')
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
await OpenClawWeixinAdapter._get_component_bytes(component)
|
||||
|
||||
@@ -10,6 +10,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
from langbot.libs.qq_official_api.api import (
|
||||
QQ_SELECT_ACTION_PREFIX,
|
||||
QQOfficialClient,
|
||||
build_keyboard_from_select_field,
|
||||
get_select_field_options,
|
||||
resolve_select_button_action,
|
||||
@@ -49,6 +50,28 @@ def test_qq_select_button_resolves_field_and_value():
|
||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qq_seed_rejects_empty_secret_without_spinning():
|
||||
client = QQOfficialClient('', 'token', 'app-id', AsyncMock())
|
||||
|
||||
with pytest.raises(ValueError, match='must not be empty'):
|
||||
await asyncio.wait_for(client.repeat_seed(''), timeout=0.1)
|
||||
|
||||
|
||||
def test_qq_auxiliary_tasks_are_bounded():
|
||||
import langbot.pkg.core.app # noqa: F401
|
||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||
|
||||
adapter = QQOfficialAdapter.model_construct()
|
||||
adapter._background_tasks = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
|
||||
|
||||
async def callback():
|
||||
raise AssertionError('rejected callback must not run')
|
||||
|
||||
assert adapter._start_background_task(callback()) is False
|
||||
assert len(adapter._background_tasks) == 100
|
||||
|
||||
|
||||
def test_qq_select_keyboard_fits_twenty_five_options():
|
||||
form_data = _select_form_data()
|
||||
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
|
||||
|
||||
@@ -11,11 +11,21 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.telegram import (
|
||||
TelegramAdapter,
|
||||
_decode_telegram_base64_limited,
|
||||
_telegram_form_action_from_callback,
|
||||
_telegram_select_field_options,
|
||||
)
|
||||
|
||||
|
||||
def test_telegram_base64_decode_is_bounded(monkeypatch):
|
||||
import langbot.pkg.platform.sources.telegram as telegram_module
|
||||
|
||||
monkeypatch.setattr(telegram_module, '_MAX_TELEGRAM_MEDIA_BYTES', 4)
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
_decode_telegram_base64_limited('A' * 12)
|
||||
|
||||
|
||||
def _select_form_data() -> dict:
|
||||
return {
|
||||
'_current_input_field': 'choice',
|
||||
@@ -88,6 +98,18 @@ def test_telegram_form_callback_cache_preserves_pipeline_uuid():
|
||||
)
|
||||
|
||||
|
||||
def test_telegram_form_callback_cache_is_bounded():
|
||||
adapter = TelegramAdapter.model_construct()
|
||||
adapter._form_action_titles = {}
|
||||
|
||||
adapter._cache_form_action_titles(
|
||||
{f'callback-{index}': str(index) for index in range(5000)},
|
||||
now=100.0,
|
||||
)
|
||||
|
||||
assert len(adapter._form_action_titles) == adapter._MAX_FORM_ACTION_TITLES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_select_field_sends_two_column_inline_keyboard():
|
||||
bot = MagicMock()
|
||||
|
||||
@@ -108,6 +108,71 @@ async def test_pipeline_indexes_and_broadcasts_are_workspace_scoped():
|
||||
assert manager.get_stats(scope=SCOPE_A)['total_connections'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_admission_is_bounded_globally_and_per_workspace():
|
||||
manager = WebSocketConnectionManager()
|
||||
await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
scope=SCOPE_A,
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
max_connections=2,
|
||||
max_connections_per_workspace=1,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match='Workspace WebSocket'):
|
||||
await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
scope=SCOPE_A,
|
||||
pipeline_uuid='pipeline-2',
|
||||
session_type='person',
|
||||
max_connections=2,
|
||||
max_connections_per_workspace=1,
|
||||
)
|
||||
|
||||
await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
scope=SCOPE_B,
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
max_connections=2,
|
||||
max_connections_per_workspace=1,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match='WebSocket connection capacity'):
|
||||
await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
scope=WebSocketScope('instance-a', 'workspace-c', 1),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
max_connections=2,
|
||||
max_connections_per_workspace=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_scope_closes_and_removes_only_matching_connections():
|
||||
manager = WebSocketConnectionManager()
|
||||
websocket_a = Mock(close=AsyncMock())
|
||||
connection_a = await manager.add_connection(
|
||||
websocket=websocket_a,
|
||||
scope=SCOPE_A,
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
)
|
||||
connection_b = await manager.add_connection(
|
||||
websocket=Mock(close=AsyncMock()),
|
||||
scope=SCOPE_B,
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
)
|
||||
|
||||
await manager.close_scope(SCOPE_A)
|
||||
|
||||
websocket_a.close.assert_awaited_once()
|
||||
assert await manager.get_connection(connection_a.connection_id, scope=SCOPE_A) is None
|
||||
assert await manager.get_connection(connection_b.connection_id, scope=SCOPE_B) is connection_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
|
||||
manager = WebSocketConnectionManager()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.libs.wechatpad_api.api import downloadpai
|
||||
from langbot.libs.wechatpad_api.util import http_util
|
||||
|
||||
|
||||
class _Response:
|
||||
headers = {}
|
||||
|
||||
def __init__(self, chunks: list[bytes]):
|
||||
self._chunks = chunks
|
||||
|
||||
def iter_content(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield from self._chunks
|
||||
|
||||
|
||||
def test_wechatpad_response_reader_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(http_util, '_MAX_WECHATPAD_RESPONSE_BYTES', 4)
|
||||
|
||||
with pytest.raises(RuntimeError, match='exceeds the runtime limit'):
|
||||
http_util._read_requests_response_limited(_Response([b'1234', b'5']))
|
||||
|
||||
|
||||
def test_wechatpad_response_reader_requires_json_object():
|
||||
with pytest.raises(RuntimeError, match='non-object'):
|
||||
http_util._read_requests_response_limited(_Response([b'[]']))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wechatpad_media_reader_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(downloadpai, '_MAX_WECHATPAD_MEDIA_BYTES', 4)
|
||||
response = httpx.Response(200, content=b'oversized')
|
||||
|
||||
with pytest.raises(RuntimeError, match='exceeds'):
|
||||
await downloadpai._read_media_limited(response)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.libs.wecom_api.api import (
|
||||
_EXTENDED_HTTP_TIMEOUT_SECONDS,
|
||||
_decode_media_base64_limited,
|
||||
WecomClient,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wecom_extended_client_timeout_is_still_bounded() -> None:
|
||||
client = object.__new__(WecomClient)
|
||||
client._http_clients = {}
|
||||
|
||||
try:
|
||||
async with client._http_client_context(unbounded_timeout=True) as http_client:
|
||||
assert http_client.timeout.read == _EXTENDED_HTTP_TIMEOUT_SECONDS
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wecom_base64_decode_is_bounded(monkeypatch) -> None:
|
||||
import langbot.libs.wecom_api.api as wecom_api
|
||||
|
||||
monkeypatch.setattr(wecom_api, '_MAX_MEDIA_BYTES', 4)
|
||||
|
||||
with pytest.raises(ValueError, match='exceeds'):
|
||||
await _decode_media_base64_limited('MTIzNDU=')
|
||||
@@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -24,6 +25,25 @@ from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
|
||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
|
||||
|
||||
|
||||
def test_ws_callback_tasks_are_bounded():
|
||||
client = WecomBotWsClient('bot-id', 'secret', object())
|
||||
client._callback_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
|
||||
|
||||
async def callback():
|
||||
raise AssertionError('rejected callback must not run')
|
||||
|
||||
assert client._start_callback_task(callback()) is False
|
||||
assert len(client._callback_tasks) == 100
|
||||
|
||||
|
||||
def test_webhook_dispatch_tasks_are_bounded():
|
||||
client = WecomBotClient('', '', '', object(), unified_mode=True)
|
||||
client._dispatch_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
|
||||
|
||||
assert client._start_dispatch_task(Mock()) is False
|
||||
assert len(client._dispatch_tasks) == 100
|
||||
|
||||
|
||||
def test_extract_template_card_action_supports_nested_button_key():
|
||||
task_id, event_key, card_type = extract_template_card_action(
|
||||
{
|
||||
@@ -287,16 +307,9 @@ async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
|
||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||||
|
||||
chunks = [
|
||||
await client.stream_sessions.consume(session.stream_id),
|
||||
await client.stream_sessions.consume(session.stream_id),
|
||||
await client.stream_sessions.consume(session.stream_id),
|
||||
]
|
||||
assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
|
||||
('你', False),
|
||||
('你好', False),
|
||||
('你好', True),
|
||||
]
|
||||
assert session.queue.qsize() == 1
|
||||
chunk = await client.stream_sessions.consume(session.stream_id)
|
||||
assert (chunk.content, chunk.is_final) == ('你好', True)
|
||||
|
||||
|
||||
def test_human_input_payload_keeps_action_select_stage_as_buttons():
|
||||
|
||||
Reference in New Issue
Block a user