mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(cloud): harden multi-tenant runtime resources
This commit is contained in:
@@ -29,6 +29,7 @@ from langbot.pkg.api.http.context import (
|
||||
WorkspaceContext,
|
||||
)
|
||||
from langbot.pkg.api.http.service.mcp import MCPService, redact_mcp_secrets, restore_mcp_secret_placeholders
|
||||
from langbot.pkg.core.taskmgr import TaskCapacityError
|
||||
from langbot.pkg.entity.persistence.mcp import MCPServer
|
||||
from langbot.pkg.provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
@@ -1050,3 +1051,23 @@ class TestMCPServiceTestMCPServer:
|
||||
# Verify - load_mcp_server called
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||
assert task_id == 456
|
||||
|
||||
async def test_rejected_transient_test_session_is_shut_down(self):
|
||||
ap = SimpleNamespace()
|
||||
mock_session = MagicMock()
|
||||
mock_session.shutdown = AsyncMock()
|
||||
ap.tool_mgr = SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock(return_value=mock_session))
|
||||
)
|
||||
|
||||
def reject(coroutine, **_kwargs):
|
||||
coroutine.close()
|
||||
raise TaskCapacityError('capacity')
|
||||
|
||||
ap.task_mgr = SimpleNamespace(create_user_task=Mock(side_effect=reject))
|
||||
service = _service(ap)
|
||||
|
||||
with pytest.raises(TaskCapacityError, match='capacity'):
|
||||
await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'})
|
||||
|
||||
mock_session.shutdown.assert_awaited_once_with()
|
||||
|
||||
@@ -13,6 +13,8 @@ Source: src/langbot/pkg/api/http/service/space.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
@@ -28,6 +30,23 @@ from langbot.pkg.entity.persistence.user import User
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _set_response_body(response: MagicMock, body: dict | str) -> None:
|
||||
"""Configure an aiohttp-like streaming body on an HTTP response mock."""
|
||||
|
||||
raw_body = body.encode() if isinstance(body, str) else json.dumps(body).encode()
|
||||
|
||||
class Content:
|
||||
async def iter_chunked(self, _chunk_size: int):
|
||||
midpoint = max(len(raw_body) // 2, 1)
|
||||
yield raw_body[:midpoint]
|
||||
if midpoint < len(raw_body):
|
||||
yield raw_body[midpoint:]
|
||||
|
||||
response.headers = {}
|
||||
response.content = Content()
|
||||
response.charset = 'utf-8'
|
||||
|
||||
|
||||
def _create_mock_user(
|
||||
email: str = 'test@example.com',
|
||||
account_type: str = 'space',
|
||||
@@ -292,6 +311,40 @@ class TestSpaceServiceGetCredits:
|
||||
# Verify - returns cached value without API call
|
||||
assert result == 100
|
||||
|
||||
async def test_cached_credit_lookup_does_not_scan_all_users(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace(data={})
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
service = SpaceService(ap)
|
||||
|
||||
class AtMostOneStepOrderedDict(OrderedDict):
|
||||
def __iter__(self):
|
||||
iterator = super().__iter__()
|
||||
yielded = False
|
||||
|
||||
def next_entry():
|
||||
nonlocal yielded
|
||||
if yielded:
|
||||
raise AssertionError('credits cache scanned all users')
|
||||
yielded = True
|
||||
return next(iterator)
|
||||
|
||||
class AtMostOneStepIterator:
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
return next_entry()
|
||||
|
||||
return AtMostOneStepIterator()
|
||||
|
||||
now = time.time()
|
||||
service._credits_cache = AtMostOneStepOrderedDict(
|
||||
(f'user-{index}@example.com', (index, now)) for index in range(512)
|
||||
)
|
||||
|
||||
assert await service.get_credits('user-511@example.com') == 511
|
||||
|
||||
async def test_get_credits_cache_expired_refreshes(self):
|
||||
"""Refreshes expired cache."""
|
||||
# Setup
|
||||
@@ -406,6 +459,7 @@ class TestSpaceServiceRefreshToken:
|
||||
},
|
||||
}
|
||||
)
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -441,6 +495,7 @@ class TestSpaceServiceRefreshToken:
|
||||
}
|
||||
)
|
||||
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid refresh token"}')
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -467,6 +522,7 @@ class TestSpaceServiceRefreshToken:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 500
|
||||
mock_response.text = AsyncMock(return_value='Internal Server Error')
|
||||
_set_response_body(mock_response, mock_response.text.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -506,6 +562,7 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
},
|
||||
}
|
||||
)
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -535,6 +592,7 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Invalid code'})
|
||||
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid code"}')
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -573,6 +631,7 @@ class TestSpaceServiceGetUserInfoRaw:
|
||||
},
|
||||
}
|
||||
)
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -603,6 +662,7 @@ class TestSpaceServiceGetUserInfoRaw:
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Unauthorized'})
|
||||
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Unauthorized"}')
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -703,6 +763,7 @@ class TestSpaceServiceGetModels:
|
||||
},
|
||||
}
|
||||
)
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
@@ -732,6 +793,7 @@ class TestSpaceServiceGetModels:
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Unauthorized'})
|
||||
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Unauthorized"}')
|
||||
_set_response_body(mock_response, mock_response.json.return_value)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
|
||||
mock_session_obj = MagicMock()
|
||||
|
||||
@@ -29,11 +29,25 @@ from langbot.pkg.entity.errors.account import (
|
||||
SpaceAccountBindingRequiredError,
|
||||
SpaceAccountNotRegisteredError,
|
||||
)
|
||||
from langbot.pkg.utils.bounded_executor import BlockingWorkCapacityError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_password_hashing_rejects_concurrent_waiters() -> None:
|
||||
service = UserService(SimpleNamespace())
|
||||
await service._password_hash_lock.acquire()
|
||||
try:
|
||||
with pytest.raises(
|
||||
BlockingWorkCapacityError,
|
||||
match='Password hashing capacity reached',
|
||||
):
|
||||
await service._hash_password('secret')
|
||||
finally:
|
||||
service._password_hash_lock.release()
|
||||
|
||||
|
||||
class TestSpaceOAuthState:
|
||||
async def test_login_state_is_opaque_single_use(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
@@ -91,6 +105,32 @@ class TestSpaceOAuthState:
|
||||
assert consumed.account is None
|
||||
assert consumed.launch_workspace_uuid == 'workspace-a'
|
||||
|
||||
async def test_issue_state_does_not_scan_all_live_states(self, monkeypatch):
|
||||
service = UserService(SimpleNamespace())
|
||||
for _ in range(512):
|
||||
await service.issue_space_oauth_state('login')
|
||||
|
||||
class NoGlobalIterationDict(dict):
|
||||
def __iter__(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
def keys(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
def items(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
def values(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
guarded_states = NoGlobalIterationDict(service._space_oauth_states)
|
||||
monkeypatch.setattr(service, '_space_oauth_states', guarded_states)
|
||||
|
||||
state = await service.issue_space_oauth_state('login')
|
||||
|
||||
assert await service.consume_space_oauth_state(state, 'login') is None
|
||||
assert len(guarded_states) == 512
|
||||
|
||||
|
||||
def _create_mock_user(
|
||||
email: str = 'test@example.com',
|
||||
|
||||
Reference in New Issue
Block a user