mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-04 14:52:12 +08:00
chore(merge): sync master into interactive cards
This commit is contained in:
@@ -372,6 +372,17 @@ class TestMCPServiceGetMCPServers:
|
||||
class TestMCPServiceCreateMCPServer:
|
||||
"""Tests for create_mcp_server method."""
|
||||
|
||||
@pytest.mark.parametrize('mode_fields', [{}, {'mode': None}, {'mode': ''}, {'mode': 'invalid'}, {'mode': []}])
|
||||
@pytest.mark.parametrize('enabled', [False, True])
|
||||
async def test_create_invalid_mode_does_not_persist(self, mode_fields, enabled):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = _service(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='MCP server mode must be one of'):
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'invalid', 'enable': enabled, **mode_fields})
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
async def test_create_stdio_rejected_by_independent_instance_gate(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
@@ -417,11 +428,12 @@ class TestMCPServiceCreateMCPServer:
|
||||
|
||||
# Execute & Verify - 2 plugins + new server would exceed limit
|
||||
with pytest.raises(ValueError, match='Maximum number of extensions'):
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server'})
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'mode': 'remote'})
|
||||
assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list)
|
||||
ap.tool_mgr.mcp_tool_loader.get_session.assert_not_called()
|
||||
|
||||
async def test_create_mcp_server_no_limit(self):
|
||||
@pytest.mark.parametrize('mode', ['stdio', 'remote', 'sse', 'http'])
|
||||
async def test_create_mcp_server_no_limit(self, mode):
|
||||
"""Creates MCP server without limit when max_extensions=-1."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
@@ -443,7 +455,7 @@ class TestMCPServiceCreateMCPServer:
|
||||
service = _service(ap)
|
||||
|
||||
# Execute
|
||||
server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server'})
|
||||
server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'mode': mode})
|
||||
|
||||
# Verify
|
||||
assert server_uuid is not None
|
||||
@@ -452,6 +464,7 @@ class TestMCPServiceCreateMCPServer:
|
||||
assert values['uuid'] == server_uuid
|
||||
assert values['workspace_uuid'] == _CONTEXT.workspace_uuid
|
||||
assert values['name'] == 'New Server'
|
||||
assert values['mode'] == mode
|
||||
|
||||
async def test_create_mcp_server_duplicate_name_raises(self):
|
||||
"""Rejects duplicate MCP server names."""
|
||||
@@ -470,7 +483,7 @@ class TestMCPServiceCreateMCPServer:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'Existing Server'})
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'Existing Server', 'mode': 'remote'})
|
||||
|
||||
async def test_create_mcp_server_loads_server(self):
|
||||
"""Loads server into tool_mgr when enabled."""
|
||||
@@ -506,7 +519,7 @@ class TestMCPServiceCreateMCPServer:
|
||||
service = _service(ap)
|
||||
|
||||
# Execute
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True, 'mode': 'remote'})
|
||||
|
||||
# Verify - host_mcp_server was called
|
||||
values = _write_statement(ap, 'insert').compile().params
|
||||
@@ -553,7 +566,7 @@ class TestMCPServiceCreateMCPServer:
|
||||
)
|
||||
service = _service(ap)
|
||||
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True, 'mode': 'remote'})
|
||||
await asyncio.sleep(0)
|
||||
assert observed == []
|
||||
|
||||
@@ -588,7 +601,9 @@ class TestMCPServiceCreateMCPServer:
|
||||
service = _service(ap)
|
||||
|
||||
# Execute with enable=False
|
||||
server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': False})
|
||||
server_uuid = await service.create_mcp_server(
|
||||
_CONTEXT, {'name': 'New Server', 'enable': False, 'mode': 'remote'}
|
||||
)
|
||||
|
||||
# Verify the persisted flag and the configuration passed to the loader
|
||||
assert uuid.UUID(server_uuid).version == 4
|
||||
@@ -655,6 +670,35 @@ class TestMCPServiceGetMCPServerByName:
|
||||
class TestMCPServiceUpdateMCPServer:
|
||||
"""Tests for update_mcp_server method."""
|
||||
|
||||
@pytest.mark.parametrize('mode', [None, '', 'invalid', [], {}])
|
||||
@pytest.mark.parametrize('enabled', [False, True])
|
||||
async def test_update_invalid_mode_preserves_existing_configuration(self, mode, enabled):
|
||||
previous = {'uuid': 'test-uuid', 'name': 'Server', 'mode': 'remote', 'enable': enabled}
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock()))
|
||||
service = _service(ap)
|
||||
service._get_mcp_server_by_uuid_raw = AsyncMock(return_value=previous)
|
||||
snapshot = copy.deepcopy(previous)
|
||||
|
||||
with pytest.raises(ValueError, match='MCP server mode must be one of'):
|
||||
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'mode': mode})
|
||||
|
||||
assert previous == snapshot
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize('mode', ['stdio', 'remote', 'sse', 'http'])
|
||||
async def test_update_supported_modes_on_disabled_server(self, mode):
|
||||
previous = {'uuid': 'test-uuid', 'name': 'Server', 'mode': 'remote', 'enable': False}
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=_create_mock_result())),
|
||||
tool_mgr=SimpleNamespace(mcp_tool_loader=None),
|
||||
)
|
||||
service = _service(ap)
|
||||
service._get_mcp_server_by_uuid_raw = AsyncMock(return_value=previous)
|
||||
|
||||
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'mode': mode})
|
||||
|
||||
assert _write_statement(ap, 'update').compile().params['mode'] == mode
|
||||
|
||||
async def test_update_mcp_server_disable_enabled_server(self):
|
||||
"""Removes server when disabling previously enabled server."""
|
||||
# Setup
|
||||
|
||||
@@ -154,3 +154,44 @@ async def test_reset_starts_new_external_runner_conversation(context_service):
|
||||
generation = await get_reset_generation(engine, 'w', 'b', 'person_u')
|
||||
assert isinstance(generation, str) and generation and generation not in {'remote-old', 'person_u'}
|
||||
assert snapshot['conversation']['external.conversation_id'] == generation
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signed_http_reset_uses_normal_adapter_wiring_and_clears_persisted_history(context_service):
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
|
||||
from langbot.pkg.platform.sources import http_bot_signing as signing
|
||||
|
||||
service, engine = context_service
|
||||
app = SimpleNamespace(monitoring_service=service)
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid='w', placement_generation=1, bot_uuid='b')
|
||||
adapter = HttpBotAdapter(
|
||||
config={'inbound_secret': 'test-secret', 'signature_required': True},
|
||||
logger=EventLogger('test', app, context, 'b'),
|
||||
)
|
||||
# Match BotManager's injection path; no synthetic attributes or model_construct.
|
||||
assert hasattr(adapter, 'ap')
|
||||
adapter.ap = app
|
||||
adapter.set_bot_uuid('b')
|
||||
store = TranscriptStore(engine)
|
||||
for conversation in ('person_u', 'person_peer'):
|
||||
await store.append_transcript(None, 'event', conversation, 'user', bot_id='b', workspace_id='w', content='old')
|
||||
web = quart.Quart(__name__)
|
||||
body = json.dumps({'session_id': 'u', 'session_type': 'person'}).encode()
|
||||
timestamp, signature = signing.sign('test-secret', body)
|
||||
async with web.test_request_context(
|
||||
'/bots/b/reset',
|
||||
method='POST',
|
||||
data=body,
|
||||
headers={signing.HEADER_TIMESTAMP: timestamp, signing.HEADER_SIGNATURE: signature},
|
||||
):
|
||||
response, status = await adapter.handle_unified_webhook('b', 'reset', quart.request)
|
||||
assert status == 200
|
||||
assert (await response.get_json())['data'] == {'session_id': 'u', 'removed': True}
|
||||
assert not (await store.page_transcript('person_u', bot_id='b', workspace_id='w'))[0]
|
||||
assert (await store.page_transcript('person_peer', bot_id='b', workspace_id='w'))[0]
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Database-backed regressions for explicit OSS Space account binding."""
|
||||
|
||||
import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
|
||||
from langbot.pkg.entity.errors.account import AccountEmailMismatchError
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def binding_context(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "binding.db"}')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(User.__table__.create)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
@asynccontextmanager
|
||||
async def identity_uow(digest):
|
||||
async with sessions() as session, session.begin():
|
||||
yield SimpleNamespace(session=session)
|
||||
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(
|
||||
User(uuid='local-owner', user='[email protected]', normalized_email='[email protected]', password='hash')
|
||||
)
|
||||
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(identity_discovery_uow=identity_uow),
|
||||
workspace_service=SimpleNamespace(
|
||||
instance_uuid='instance',
|
||||
policy=SimpleNamespace(multi_workspace_enabled=False),
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace_uuid='workspace', workspace_created_at=datetime.datetime(2026, 1, 1)
|
||||
)
|
||||
),
|
||||
),
|
||||
space_service=SimpleNamespace(
|
||||
exchange_oauth_code=AsyncMock(return_value={'access_token': 'access', 'refresh_token': 'refresh'}),
|
||||
get_user_info_raw=AsyncMock(
|
||||
return_value={'account': {'uuid': 'space-owner', 'email': ' [email protected] '}, 'api_key': 'key'}
|
||||
),
|
||||
),
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'jwt': {'secret': 'test-secret-long-enough-for-jwt-32', 'expire': 3600}}}
|
||||
),
|
||||
)
|
||||
service = UserService(application)
|
||||
service._update_space_provider_for_account = AsyncMock()
|
||||
yield service, sessions
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_sole_local_account_adopts_space_email_and_preserves_identity(binding_context):
|
||||
service, _ = binding_context
|
||||
old_token = await service.generate_jwt_token(await service.get_user_by_uuid('local-owner'))
|
||||
account = await service.bind_space_account('[email protected]', 'code')
|
||||
assert account.uuid == 'local-owner'
|
||||
assert account.user == account.normalized_email == '[email protected]'
|
||||
assert account.password == 'hash'
|
||||
assert account.account_type == 'space'
|
||||
assert account.space_account_uuid == 'space-owner'
|
||||
assert account.space_access_token == 'access'
|
||||
assert account.space_refresh_token == 'refresh'
|
||||
assert account.space_api_key == 'key'
|
||||
assert await service.get_user_by_email('[email protected]') is None
|
||||
assert (await service.get_authenticated_account(old_token)).uuid == account.uuid
|
||||
new_token = await service.generate_jwt_token(account)
|
||||
assert (await service.get_authenticated_account(new_token)).user == '[email protected]'
|
||||
service._update_space_provider_for_account.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', ['active', 'disabled', 'deleted'])
|
||||
async def test_multiple_accounts_keep_email_check(binding_context, status):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(
|
||||
User(
|
||||
uuid='other', user='[email protected]', normalized_email='[email protected]', password='', status=status
|
||||
)
|
||||
)
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
account = await service.get_user_by_uuid('local-owner')
|
||||
assert account.user == '[email protected]'
|
||||
assert account.space_account_uuid is None
|
||||
|
||||
|
||||
async def test_same_email_binding_still_works_with_multiple_accounts(binding_context):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(User(uuid='other', user='[email protected]', normalized_email='[email protected]', password=''))
|
||||
service.ap.space_service.get_user_info_raw.return_value['account']['email'] = '[email protected]'
|
||||
account = await service.bind_space_account('[email protected]', 'code')
|
||||
assert account.space_account_uuid == 'space-owner'
|
||||
|
||||
|
||||
async def test_cloud_binding_is_rejected_before_oauth(binding_context):
|
||||
service, _ = binding_context
|
||||
service.ap.workspace_service.policy.multi_workspace_enabled = True
|
||||
with pytest.raises(ControlPlaneDirectoryRequiredError):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
service.ap.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'field,value', [('source', 'cloud_projection'), ('account_type', 'space'), ('space_account_uuid', 'already-bound')]
|
||||
)
|
||||
async def test_only_unbound_local_identity_can_replace_email(binding_context, field, value):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
account = await session.get(User, 1)
|
||||
setattr(account, field, value)
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
|
||||
|
||||
async def test_space_subject_bound_to_another_account_is_rejected(binding_context):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(
|
||||
User(
|
||||
uuid='other',
|
||||
user='[email protected]',
|
||||
normalized_email='[email protected]',
|
||||
password='',
|
||||
space_account_uuid='space-owner',
|
||||
)
|
||||
)
|
||||
service.ap.space_service.get_user_info_raw.return_value['account']['email'] = '[email protected]'
|
||||
with pytest.raises(ValueError, match='already bound'):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
assert (await service.get_user_by_uuid('local-owner')).space_account_uuid is None
|
||||
Reference in New Issue
Block a user