mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 12:20:58 +00:00
e1ac5e0fc8
* 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>
493 lines
14 KiB
Python
493 lines
14 KiB
Python
"""
|
|
Message and query factories for tests.
|
|
|
|
Provides reusable factories for creating message chains, events, and query objects.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, Mock
|
|
import typing
|
|
|
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
|
|
|
|
# Counter for generating unique IDs
|
|
_query_counter = 0
|
|
|
|
|
|
def _next_query_id() -> int:
|
|
"""Generate a unique query ID."""
|
|
global _query_counter
|
|
_query_counter += 1
|
|
return _query_counter
|
|
|
|
|
|
# ============== Message Chain Factories ==============
|
|
|
|
|
|
def text_chain(text: str = 'hello') -> platform_message.MessageChain:
|
|
"""Create a simple text message chain."""
|
|
return platform_message.MessageChain(
|
|
[
|
|
platform_message.Plain(text=text),
|
|
]
|
|
)
|
|
|
|
|
|
def group_text_chain(text: str = 'hello') -> platform_message.MessageChain:
|
|
"""Create a group text message chain (same as text_chain, context provided by event)."""
|
|
return text_chain(text)
|
|
|
|
|
|
def mention_chain(
|
|
text: str = 'hello',
|
|
target: typing.Union[int, str] = 12345,
|
|
) -> platform_message.MessageChain:
|
|
"""Create a message chain with @mention."""
|
|
return platform_message.MessageChain(
|
|
[
|
|
platform_message.At(target=target),
|
|
platform_message.Plain(text=f' {text}'),
|
|
]
|
|
)
|
|
|
|
|
|
def image_chain(
|
|
text: str = '',
|
|
url: str = 'https://example.com/image.png',
|
|
) -> platform_message.MessageChain:
|
|
"""Create a message chain with an image."""
|
|
components = []
|
|
if text:
|
|
components.append(platform_message.Plain(text=text))
|
|
components.append(platform_message.Image(url=url))
|
|
return platform_message.MessageChain(components)
|
|
|
|
|
|
def command_chain(
|
|
command: str = 'help',
|
|
prefix: str = '/',
|
|
) -> platform_message.MessageChain:
|
|
"""Create a command message chain."""
|
|
return platform_message.MessageChain(
|
|
[
|
|
platform_message.Plain(text=f'{prefix}{command}'),
|
|
]
|
|
)
|
|
|
|
|
|
# ============== Message Event Factories ==============
|
|
|
|
|
|
def friend_message_event(
|
|
message_chain: platform_message.MessageChain,
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
nickname: str = 'TestUser',
|
|
) -> platform_events.FriendMessage:
|
|
"""Create a friend (private) message event."""
|
|
sender = platform_entities.Friend(
|
|
id=sender_id,
|
|
nickname=nickname,
|
|
remark=None,
|
|
)
|
|
return platform_events.FriendMessage(
|
|
type='FriendMessage',
|
|
sender=sender,
|
|
message_chain=message_chain,
|
|
time=1609459200,
|
|
)
|
|
|
|
|
|
def group_message_event(
|
|
message_chain: platform_message.MessageChain,
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
sender_name: str = 'TestUser',
|
|
group_id: typing.Union[int, str] = 99999,
|
|
group_name: str = 'TestGroup',
|
|
) -> platform_events.GroupMessage:
|
|
"""Create a group message event."""
|
|
group = platform_entities.Group(
|
|
id=group_id,
|
|
name=group_name,
|
|
permission=platform_entities.Permission.Member,
|
|
)
|
|
sender = platform_entities.GroupMember(
|
|
id=sender_id,
|
|
member_name=sender_name,
|
|
permission=platform_entities.Permission.Member,
|
|
group=group,
|
|
)
|
|
return platform_events.GroupMessage(
|
|
type='GroupMessage',
|
|
sender=sender,
|
|
message_chain=message_chain,
|
|
time=1609459200,
|
|
)
|
|
|
|
|
|
# ============== Mock Adapter Factory ==============
|
|
|
|
|
|
def mock_adapter() -> Mock:
|
|
"""Create a mock platform adapter."""
|
|
adapter = AsyncMock()
|
|
adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
|
adapter.reply_message = AsyncMock()
|
|
adapter.reply_message_chunk = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
# ============== Query Factories ==============
|
|
|
|
|
|
def _base_query(
|
|
message_chain: platform_message.MessageChain,
|
|
message_event: platform_events.MessageEvent,
|
|
launcher_type: provider_session.LauncherTypes,
|
|
launcher_id: typing.Union[int, str],
|
|
sender_id: typing.Union[int, str],
|
|
adapter: Mock,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a base query with model_construct to bypass validation."""
|
|
query_id = _next_query_id()
|
|
|
|
base_data = {
|
|
'query_id': query_id,
|
|
'launcher_type': launcher_type,
|
|
'launcher_id': launcher_id,
|
|
'sender_id': sender_id,
|
|
'message_chain': message_chain,
|
|
'message_event': message_event,
|
|
'adapter': adapter,
|
|
'pipeline_uuid': 'test-pipeline-uuid',
|
|
'bot_uuid': 'test-bot-uuid',
|
|
'pipeline_config': {
|
|
'ai': {
|
|
'runner': {'runner': 'local-agent'},
|
|
'local-agent': {
|
|
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
|
|
'prompt': 'test-prompt',
|
|
},
|
|
},
|
|
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
|
'trigger': {'misc': {'combine-quote-message': False}},
|
|
},
|
|
'session': None,
|
|
'prompt': None,
|
|
'messages': [],
|
|
'user_message': None,
|
|
'use_funcs': [],
|
|
'use_llm_model_uuid': None,
|
|
'variables': {},
|
|
'resp_messages': [],
|
|
'resp_message_chain': None,
|
|
'current_stage_name': None,
|
|
}
|
|
|
|
# Apply overrides
|
|
for key, value in overrides.items():
|
|
base_data[key] = value
|
|
|
|
query = pipeline_query.Query.model_construct(**base_data)
|
|
object.__setattr__(
|
|
query,
|
|
'_execution_context',
|
|
ExecutionContext(
|
|
instance_uuid='test-instance',
|
|
workspace_uuid='test-workspace',
|
|
placement_generation=1,
|
|
bot_uuid=query.bot_uuid,
|
|
pipeline_uuid=query.pipeline_uuid,
|
|
query_uuid=query.query_uuid,
|
|
),
|
|
)
|
|
return query
|
|
|
|
|
|
def text_query(
|
|
text: str = 'hello',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a basic text query (private chat)."""
|
|
chain = text_chain(text)
|
|
event = friend_message_event(chain, sender_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def private_text_query(
|
|
text: str = 'hello',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a private text query (alias for text_query)."""
|
|
return text_query(text, sender_id, **overrides)
|
|
|
|
|
|
def group_text_query(
|
|
text: str = 'hello',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
group_id: typing.Union[int, str] = 99999,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a group text query."""
|
|
chain = text_chain(text)
|
|
event = group_message_event(chain, sender_id, group_id=group_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
|
launcher_id=group_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def command_query(
|
|
command: str = 'help',
|
|
prefix: str = '/',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a command-like query."""
|
|
chain = command_chain(command, prefix)
|
|
event = friend_message_event(chain, sender_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def mention_query(
|
|
text: str = 'hello',
|
|
target: typing.Union[int, str] = 12345,
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
group_id: typing.Union[int, str] = 99999,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a mention-bot query (group chat with @mention)."""
|
|
chain = mention_chain(text, target)
|
|
event = group_message_event(chain, sender_id, group_id=group_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
|
launcher_id=group_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def empty_query(**overrides) -> pipeline_query.Query:
|
|
"""Create an empty message query."""
|
|
chain = platform_message.MessageChain([])
|
|
event = friend_message_event(chain)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=12345,
|
|
sender_id=12345,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def image_query(
|
|
text: str = '',
|
|
url: str = 'https://example.com/image.png',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create an image query."""
|
|
chain = image_chain(text, url)
|
|
event = friend_message_event(chain, sender_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def file_query(
|
|
url: str = 'https://example.com/document.pdf',
|
|
name: str = 'document.pdf',
|
|
text: str = '',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a file attachment query."""
|
|
components = []
|
|
if text:
|
|
components.append(platform_message.Plain(text=text))
|
|
components.append(platform_message.File(url=url, name=name))
|
|
chain = platform_message.MessageChain(components)
|
|
event = friend_message_event(chain, sender_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def unsupported_query(
|
|
unsupported_type: str = 'CustomComponent',
|
|
text: str = '',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a query with unsupported/unknown message segment."""
|
|
components = []
|
|
if text:
|
|
components.append(platform_message.Plain(text=text))
|
|
# Use Unknown component for unsupported types
|
|
components.append(platform_message.Unknown(text=f'Unsupported: {unsupported_type}'))
|
|
chain = platform_message.MessageChain(components)
|
|
event = friend_message_event(chain, sender_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def query_with_session(
|
|
text: str = 'hello',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
session: provider_session.Session = None,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a query with a session object.
|
|
|
|
If session is None, creates a default session with empty conversation.
|
|
"""
|
|
if session is None:
|
|
# Create a default session
|
|
session = provider_session.Session(
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
use_prompt_name='default',
|
|
using_conversation=None,
|
|
conversations=[],
|
|
)
|
|
|
|
return text_query(text, sender_id, session=session, **overrides)
|
|
|
|
|
|
def query_with_config(
|
|
text: str = 'hello',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
pipeline_config: dict = None,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a query with custom pipeline configuration.
|
|
|
|
If pipeline_config is None, uses default config.
|
|
Useful for testing specific stage behaviors.
|
|
"""
|
|
if pipeline_config is None:
|
|
pipeline_config = {
|
|
'ai': {
|
|
'runner': {'runner': 'local-agent'},
|
|
'local-agent': {
|
|
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
|
|
'prompt': 'test-prompt',
|
|
},
|
|
},
|
|
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
|
'trigger': {'misc': {'combine-quote-message': False}},
|
|
}
|
|
|
|
return text_query(text, sender_id, pipeline_config=pipeline_config, **overrides)
|
|
|
|
|
|
def voice_query(
|
|
url: str = 'https://example.com/audio.mp3',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a voice/audio query."""
|
|
components = [
|
|
platform_message.Voice(url=url),
|
|
]
|
|
chain = platform_message.MessageChain(components)
|
|
event = friend_message_event(chain, sender_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
|
launcher_id=sender_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|
|
|
|
|
|
def at_all_query(
|
|
text: str = 'hello',
|
|
sender_id: typing.Union[int, str] = 12345,
|
|
group_id: typing.Union[int, str] = 99999,
|
|
**overrides,
|
|
) -> pipeline_query.Query:
|
|
"""Create a group query with @All mention."""
|
|
components = [
|
|
platform_message.AtAll(),
|
|
platform_message.Plain(text=f' {text}'),
|
|
]
|
|
chain = platform_message.MessageChain(components)
|
|
event = group_message_event(chain, sender_id, group_id=group_id)
|
|
adapter = mock_adapter()
|
|
return _base_query(
|
|
message_chain=chain,
|
|
message_event=event,
|
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
|
launcher_id=group_id,
|
|
sender_id=sender_id,
|
|
adapter=adapter,
|
|
**overrides,
|
|
)
|