chore(merge): sync master into dev/4.11.x

This commit is contained in:
huanghuoguoguo
2026-07-31 19:29:38 +08:00
502 changed files with 77975 additions and 12729 deletions
+56 -3
View File
@@ -9,7 +9,8 @@ Run: uv run pytest tests/integration/api/test_bots.py -q
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from unittest.mock import ANY, MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -65,12 +66,43 @@ def fake_bot_app():
)
# Auth services
account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='workspace-test'),
membership=SimpleNamespace(
uuid='membership-test',
role='owner',
projection_revision=0,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
app.apikey_service.authenticate_api_key = AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-test',
placement_generation=1,
api_key_uuid='api-key-test',
workspace_uuid='workspace-test',
permissions=frozenset(
{
'resource.view',
'resource.manage',
'runtime.operate',
'provider_secret.manage',
}
),
)
)
# Bot service
app.bot_service = Mock()
@@ -252,6 +284,25 @@ class TestBotLogsEndpoint:
assert 'logs' in data['data']
assert 'total_count' in data['data']
@pytest.mark.asyncio
async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app):
access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value
original_role = access.membership.role
access.membership.role = 'viewer'
fake_bot_app.bot_service.list_event_logs.reset_mock()
try:
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/logs',
headers={'Authorization': 'Bearer test_token'},
json={'from_index': -1, 'max_count': 10},
)
finally:
access.membership.role = original_role
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
fake_bot_app.bot_service.list_event_logs.assert_awaited_once()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEventRouteDryRunEndpoint:
@@ -286,6 +337,7 @@ class TestBotEventRouteDryRunEndpoint:
assert data['data']['target']['target_name'] == 'Member Agent'
assert data['data']['diagnostic_steps'][0].startswith('Route 1')
fake_bot_app.bot_service.dry_run_event_route.assert_awaited_with(
ANY,
bot_uuid='test-bot-uuid',
event_type='platform.member.joined',
event_data={'room': {'id': 'room-1'}},
@@ -318,7 +370,7 @@ class TestBotEventRouteStatusEndpoint:
assert data['code'] == 0
assert data['data']['routes'][0]['binding_id'] == 'binding-1'
assert data['data']['routes'][0]['last_status'] == 'delivered'
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid')
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -343,6 +395,7 @@ class TestBotEventRouteTestEndpoint:
assert data['data']['dispatched'] is True
assert data['data']['event_type'] == 'message.received'
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
ANY,
bot_uuid='test-bot-uuid',
event_type='message.received',
payload={'message_text': 'hello'},
+119
View File
@@ -0,0 +1,119 @@
"""Authorization tests for sensitive Box runtime observability."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from langbot.pkg.api.http.controller.groups.box import BoxRouterGroup
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
def _access(account_uuid: str):
return SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(
uuid=f'member-{account_uuid}',
role='viewer' if account_uuid == 'viewer-account' else 'owner',
projection_revision=1,
),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
@pytest.fixture
async def box_security_api():
accounts = {
'viewer-token': SimpleNamespace(uuid='viewer-account', user='viewer@example.com'),
'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'),
}
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
)
application.box_service.get_status = AsyncMock(return_value={'enabled': True})
application.box_service.get_backend_status = AsyncMock(
return_value={'available': True, 'enabled': True, 'backend': {'name': 'nsjail'}}
)
application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
application.box_service.managed_admission_required = False
quart_app = quart.Quart(__name__)
router = BoxRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
def _headers(token: str) -> dict[str, str]:
return {
'Authorization': f'Bearer {token}',
'X-Workspace-Id': WORKSPACE_UUID,
}
@pytest.mark.asyncio
async def test_viewer_can_read_status_but_not_sessions_or_errors(box_security_api):
application, client = box_security_api
status = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
sessions = await client.get('/api/v1/box/sessions', headers=_headers('viewer-token'))
errors = await client.get('/api/v1/box/errors', headers=_headers('viewer-token'))
assert status.status_code == 200
assert sessions.status_code == 403
assert errors.status_code == 403
application.box_service.get_sessions.assert_not_awaited()
application.box_service.get_recent_errors.assert_not_called()
@pytest.mark.asyncio
async def test_owner_can_audit_box_sessions_and_errors(box_security_api):
application, client = box_security_api
sessions = await client.get('/api/v1/box/sessions', headers=_headers('owner-token'))
errors = await client.get('/api/v1/box/errors', headers=_headers('owner-token'))
assert sessions.status_code == 200
assert errors.status_code == 200
application.box_service.get_sessions.assert_awaited_once()
application.box_service.get_recent_errors.assert_called_once()
@pytest.mark.asyncio
async def test_box_status_returns_explicit_403_when_workspace_has_no_managed_sandbox(box_security_api):
application, client = box_security_api
application.box_service.get_status.side_effect = EntitlementUnavailableError(
'Workspace entitlement does not grant managed_sandbox'
)
response = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
assert response.status_code == 403
payload = await response.get_json()
assert payload['code'] == 'managed_sandbox_unavailable'
@pytest.mark.asyncio
async def test_runtime_status_reports_connector_health_without_consuming_workspace_entitlement(box_security_api):
application, client = box_security_api
application.box_service.get_status.side_effect = EntitlementUnavailableError(
'Workspace entitlement does not grant managed_sandbox'
)
response = await client.get('/api/v1/box/runtime-status', headers=_headers('viewer-token'))
assert response.status_code == 200
assert (await response.get_json())['data']['available'] is True
application.box_service.get_backend_status.assert_awaited_once()
application.box_service.get_status.assert_not_awaited()
+110 -7
View File
@@ -8,8 +8,11 @@ Run: uv run pytest tests/integration/api/test_embed.py -q
from __future__ import annotations
import json
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -80,10 +83,19 @@ def fake_embed_app():
mock_runtime_bot = Mock()
mock_runtime_bot.bot_entity = mock_bot_entity
mock_runtime_bot.get_pipeline_target_for_event_type = Mock(return_value='test-pipeline-uuid')
mock_runtime_bot.execution_context = SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
# Platform manager with bots
app.platform_mgr = Mock()
app.platform_mgr.bots = [mock_runtime_bot]
app.platform_mgr.resolve_public_bot = AsyncMock(
side_effect=lambda route_key: mock_runtime_bot if route_key == mock_bot_entity.uuid else None
)
# WebSocket proxy bot with adapter
mock_websocket_adapter = Mock()
@@ -94,6 +106,16 @@ def fake_embed_app():
mock_ws_proxy_bot = Mock()
mock_ws_proxy_bot.adapter = mock_websocket_adapter
app.platform_mgr.websocket_proxy_bot = mock_ws_proxy_bot
app.platform_mgr.get_websocket_proxy_bot = AsyncMock(return_value=mock_ws_proxy_bot)
app.workspace_service = SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
)
)
# Monitoring service for feedback
app.monitoring_service = Mock()
@@ -117,12 +139,13 @@ class TestEmbedWidgetEndpoint:
"""Tests for widget.js endpoint."""
@pytest.mark.asyncio
async def test_get_widget_js_success(self, quart_test_client):
async def test_get_widget_js_success(self, quart_test_client, fake_embed_app):
"""GET /api/v1/embed/{bot_uuid}/widget.js returns JS."""
response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js')
assert response.status_code == 200
assert 'javascript' in response.content_type
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
@pytest.mark.asyncio
async def test_get_widget_js_invalid_uuid(self, quart_test_client):
@@ -203,9 +226,8 @@ class TestEmbedMessagesEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.get_websocket_messages.assert_called_with(
'test-pipeline-uuid', 'person', SESSION_ID
)
proxy_bot = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value
proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
@@ -253,9 +275,8 @@ class TestEmbedResetEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.reset_session.assert_called_with(
'test-pipeline-uuid', 'person', SESSION_ID
)
proxy_bot = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value
proxy_bot.adapter.reset_session.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_reset_session_requires_session_id(self, quart_test_client):
@@ -316,3 +337,85 @@ class TestEmbedFeedbackEndpoint:
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedWebSocketEndpoint:
"""The public socket authenticates before resolving shared runtime state."""
@pytest.mark.asyncio
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'authenticate', 'token': ''}))
connected = json.loads(await websocket.receive())
assert connected['type'] == 'connected'
assert connected['bot_uuid'] == 'a1b2c3d4-5678-90ab-cdef-123456789abc'
await websocket.send(json.dumps({'type': 'disconnect'}))
fake_embed_app.workspace_service.get_execution_binding.assert_awaited_with(
'workspace-test',
expected_generation=1,
)
@pytest.mark.asyncio
async def test_rejects_non_auth_first_frame_before_runtime_lookup(self, quart_test_client, fake_embed_app):
fake_embed_app.platform_mgr.get_websocket_proxy_bot.reset_mock()
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'message', 'message': []}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
fake_embed_app.platform_mgr.get_websocket_proxy_bot.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejects_invalid_turnstile_session_before_runtime_lookup(self, quart_test_client, fake_embed_app):
fake_embed_app.platform_mgr.get_websocket_proxy_bot.reset_mock()
config = fake_embed_app.platform_mgr.resolve_public_bot.side_effect(
'a1b2c3d4-5678-90ab-cdef-123456789abc'
).bot_entity.adapter_config
config['turnstile_secret_key'] = 'test-secret'
try:
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'authenticate', 'token': 'invalid'}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
finally:
config['turnstile_secret_key'] = ''
fake_embed_app.platform_mgr.get_websocket_proxy_bot.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejects_message_when_bot_is_disabled_after_connect(self, quart_test_client, fake_embed_app):
runtime_bot = fake_embed_app.platform_mgr.resolve_public_bot.side_effect('a1b2c3d4-5678-90ab-cdef-123456789abc')
adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
adapter.handle_websocket_message.reset_mock()
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'authenticate', 'token': ''}))
assert json.loads(await websocket.receive())['type'] == 'connected'
runtime_bot.bot_entity.enable = False
try:
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Bot is unavailable'}
finally:
runtime_bot.bot_entity.enable = True
adapter.handle_websocket_message.assert_not_awaited()
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from quart import Quart
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.api.http.controller.groups.workspaces import WorkspacesRouterGroup
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.utils import constants
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.service import WorkspaceService
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
def _authorization(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
headers = {'Authorization': f'Bearer {token}'}
if workspace_uuid is not None:
headers['X-Workspace-Id'] = workspace_uuid
return headers
async def test_fresh_oss_workspace_http_journey_uses_real_sqlite_persistence(
tmp_path,
monkeypatch,
):
"""Exercise the first-run Workspace journey through real HTTP handlers."""
instance_uuid = 'fresh-oss-workspace-journey'
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
application = SimpleNamespace(
logger=logging.getLogger('fresh-oss-workspace-journey-test'),
instance_config=SimpleNamespace(
data={
'database': {
'use': 'sqlite',
'sqlite': {'path': str(tmp_path / 'langbot.db')},
},
'system': {
'jwt': {'secret': 'fresh-oss-workspace-secret', 'expire': 3600},
'allow_modify_login_info': True,
'limitation': {},
'outbound_ips': [],
},
'api': {'global_api_key': ''},
'plugin': {'enable_marketplace': True},
'space': {
'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
'disable_models_service': False,
},
'mcp': {'stdio': {'enabled': True}},
}
),
)
persistence = PersistenceManager(application)
application.persistence_mgr = persistence
await persistence.initialize()
try:
application.workspace_service = WorkspaceService(
application,
instance_uuid=instance_uuid,
)
application.workspace_collaboration_service = WorkspaceCollaborationService(
application,
application.workspace_service,
)
application.user_service = UserService(application)
quart_app = Quart(__name__)
await UserRouterGroup(application, quart_app).initialize()
await WorkspacesRouterGroup(application, quart_app).initialize()
await SystemRouterGroup(application, quart_app).initialize()
client = quart_app.test_client()
initialization = await client.get('/api/v1/user/init')
assert initialization.status_code == 200
assert (await initialization.get_json())['data'] == {'initialized': False}
initialized = await client.post(
'/api/v1/user/init',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert initialized.status_code == 200
authenticated = await client.post(
'/api/v1/user/auth',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert authenticated.status_code == 200
token = (await authenticated.get_json())['data']['token']
bootstrap = await client.get(
'/api/v1/workspaces/bootstrap',
headers=_authorization(token),
)
assert bootstrap.status_code == 200
bootstrap_workspaces = (await bootstrap.get_json())['data']['workspaces']
assert len(bootstrap_workspaces) == 1
bootstrap_access = bootstrap_workspaces[0]
workspace_uuid = bootstrap_access['workspace']['uuid']
assert bootstrap_access['workspace'] == {
'uuid': workspace_uuid,
'instance_uuid': instance_uuid,
'name': 'Default Workspace',
'slug': 'default',
'type': 'team',
'status': 'active',
'source': 'local',
}
assert bootstrap_access['membership']['email'] == 'owner@example.com'
assert bootstrap_access['membership']['role'] == 'owner'
assert 'workspace.update' in bootstrap_access['permissions']
current = await client.get(
'/api/v1/workspaces/current',
headers=_authorization(token, workspace_uuid),
)
assert current.status_code == 200
current_data = (await current.get_json())['data']
assert current_data['workspace']['uuid'] == workspace_uuid
assert current_data['membership']['account_uuid'] == bootstrap_access['membership']['account_uuid']
assert current_data['membership']['role'] == 'owner'
user_info = await client.get(
'/api/v1/user/info',
headers=_authorization(token, workspace_uuid),
)
assert user_info.status_code == 200
assert (await user_info.get_json())['data'] == {
'account_uuid': bootstrap_access['membership']['account_uuid'],
'user': 'owner@example.com',
'account_type': 'local',
'has_password': True,
}
initial_system_info = await client.get(
'/api/v1/system/info',
headers=_authorization(token, workspace_uuid),
)
assert initial_system_info.status_code == 200
assert (await initial_system_info.get_json())['data']['wizard_status'] == 'none'
assert (await initial_system_info.get_json())['data']['wizard_progress'] is None
progress = {'step': 2, 'selected_adapter': 'telegram', 'bot_saved': False}
updated_progress = await client.put(
'/api/v1/system/wizard/progress',
headers=_authorization(token, workspace_uuid),
json=progress,
)
assert updated_progress.status_code == 200
persisted_progress = await persistence.execute_async(
sa.select(WorkspaceMetadata.value).where(
WorkspaceMetadata.workspace_uuid == workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
)
assert json.loads(persisted_progress.scalar_one()) == progress
system_info_with_progress = await client.get(
'/api/v1/system/info',
headers=_authorization(token, workspace_uuid),
)
assert system_info_with_progress.status_code == 200
progress_data = (await system_info_with_progress.get_json())['data']
assert progress_data['wizard_status'] == 'none'
assert progress_data['wizard_progress'] == progress
completed = await client.post(
'/api/v1/system/wizard/completed',
headers=_authorization(token, workspace_uuid),
json={'status': 'completed'},
)
assert completed.status_code == 200
completed_system_info = await client.get(
'/api/v1/system/info',
headers=_authorization(token, workspace_uuid),
)
assert completed_system_info.status_code == 200
completed_data = (await completed_system_info.get_json())['data']
assert completed_data['wizard_status'] == 'completed'
assert completed_data['wizard_progress'] is None
rejected_workspace = await client.post(
'/api/v1/workspaces',
headers=_authorization(token, workspace_uuid),
json={'name': 'Second Workspace'},
)
assert rejected_workspace.status_code == 403
rejected_data = await rejected_workspace.get_json()
assert rejected_data['code'] == 'edition_limit'
persisted_wizard_status = await persistence.execute_async(
sa.select(WorkspaceMetadata.value).where(
WorkspaceMetadata.workspace_uuid == workspace_uuid,
WorkspaceMetadata.key == 'wizard_status',
)
)
assert persisted_wizard_status.scalar_one() == 'completed'
finally:
await persistence.get_db_engine().dispose()
+23 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -69,7 +70,28 @@ def fake_knowledge_app():
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
account = SimpleNamespace(
uuid='00000000-0000-0000-0000-000000000001',
user='test@example.com',
)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
execution=SimpleNamespace(
instance_uuid='instance-knowledge-api',
placement_generation=1,
),
workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
membership=SimpleNamespace(
uuid='00000000-0000-0000-0000-000000000010',
role='owner',
projection_revision=0,
),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+49 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -66,7 +67,26 @@ def fake_monitoring_app():
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(
return_value=SimpleNamespace(
uuid='account-uuid',
user='test@example.com',
email='test@example.com',
)
)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
membership=SimpleNamespace(
uuid='membership-uuid',
role='owner',
projection_revision=1,
),
)
)
)
# Monitoring service
app.monitoring_service = Mock()
@@ -135,6 +155,34 @@ class TestMonitoringOverviewEndpoint:
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_viewer_can_read_monitoring_but_cannot_export(
self,
quart_test_client,
fake_monitoring_app,
):
"""Ordinary monitoring is resource.view; export remains data.export."""
membership = (
fake_monitoring_app.workspace_collaboration_service.resolve_account_workspace.return_value.membership
)
original_role = membership.role
membership.role = 'viewer'
try:
response = await quart_test_client.get(
'/api/v1/monitoring/overview',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
export_response = await quart_test_client.get(
'/api/v1/monitoring/export?type=messages',
headers={'Authorization': 'Bearer test_token'},
)
assert export_response.status_code == 403
assert (await export_response.get_json())['code'] == 'permission_denied'
finally:
membership.role = original_role
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringMessagesEndpoint:
+169 -11
View File
@@ -9,10 +9,13 @@ Run: uv run pytest tests/integration/api/test_pipelines.py -q
from __future__ import annotations
import json
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration
@@ -54,6 +57,7 @@ def mock_circular_import_chain():
):
# Import groups after mocking to populate preregistered_groups
import langbot.pkg.api.http.controller.groups.pipelines.pipelines as _pipelines # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as _websocket_chat # noqa: E402, F401
yield
@@ -75,10 +79,25 @@ def fake_pipeline_app():
)
# Auth services
account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='workspace-test'),
membership=SimpleNamespace(
uuid='membership-test',
role='owner',
projection_revision=0,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
@@ -119,6 +138,15 @@ def fake_pipeline_app():
app.bot_service.get_bots = AsyncMock(return_value=[])
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
# Workspace-scoped dashboard WebSocket proxy
websocket_adapter = Mock()
websocket_adapter.get_websocket_messages = Mock(return_value=[])
websocket_adapter.reset_session = Mock()
websocket_adapter.handle_websocket_message = AsyncMock()
websocket_proxy_bot = Mock(adapter=websocket_adapter)
app.platform_mgr = Mock()
app.platform_mgr.get_websocket_proxy_bot = AsyncMock(return_value=websocket_proxy_bot)
# MCP service (for extensions endpoint)
app.mcp_service = Mock()
app.mcp_service.get_mcp_servers = AsyncMock(return_value=[])
@@ -280,15 +308,145 @@ class TestPipelineExtensionsEndpoint:
assert data['code'] == 0
@pytest.mark.asyncio
async def test_get_extensions_tolerates_skill_list_failure(self, quart_test_client, fake_pipeline_app):
"""Pipeline plugin/MCP binding UI should not fail when Box skill listing is unavailable."""
fake_pipeline_app.skill_service.list_skills = AsyncMock(side_effect=TimeoutError('box_list_skills timeout'))
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'}
)
async def test_get_extensions_redacts_available_plugin_secrets(
self,
quart_test_client,
fake_pipeline_app,
):
connector = fake_pipeline_app.plugin_connector
raw_plugin = {
'plugin_config': {'apiKey': 'plugin-secret'},
'debug': {'plugin_debug_key': 'debug-secret'},
}
connector.list_plugins.return_value = [raw_plugin]
try:
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions',
headers={'Authorization': 'Bearer test_token'},
)
finally:
connector.list_plugins.return_value = []
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['available_skills'] == []
plugin = (await response.get_json())['data']['available_plugins'][0]
assert plugin['plugin_config']['apiKey'] == '***'
assert plugin['debug']['plugin_debug_key'] == '***'
assert raw_plugin['plugin_config']['apiKey'] == 'plugin-secret'
@pytest.mark.asyncio
async def test_get_extensions_hides_connector_bound_to_another_workspace(
self,
quart_test_client,
fake_pipeline_app,
):
connector = fake_pipeline_app.plugin_connector
original_enabled = connector.is_enable_plugin
connector.is_enable_plugin = True
connector.require_workspace_context.reset_mock()
connector.list_plugins.reset_mock()
connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
try:
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions',
headers={'Authorization': 'Bearer test_token'},
)
finally:
connector.require_workspace_context.side_effect = None
connector.is_enable_plugin = original_enabled
assert response.status_code == 404
connector.list_plugins.assert_not_awaited()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineDashboardWebSocket:
@pytest.mark.asyncio
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app):
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(
json.dumps(
{
'type': 'authenticate',
'token': 'test_token',
'workspace_uuid': 'workspace-test',
}
)
)
connected = json.loads(await websocket.receive())
assert connected['type'] == 'connected'
assert connected['pipeline_uuid'] == 'test-pipeline-uuid'
await websocket.send(json.dumps({'type': 'disconnect'}))
fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
'account-test',
'workspace-test',
)
fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited()
@pytest.mark.asyncio
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client):
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'message', 'message': []}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
@pytest.mark.asyncio
async def test_websocket_rechecks_revocable_membership_before_each_message(
self,
quart_test_client,
fake_pipeline_app,
):
access = fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.return_value
adapter = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
original_role = access.membership.role
adapter.handle_websocket_message.reset_mock()
try:
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(
json.dumps(
{
'type': 'authenticate',
'token': 'test_token',
'workspace_uuid': 'workspace-test',
}
)
)
assert json.loads(await websocket.receive())['type'] == 'connected'
access.membership.role = 'viewer'
await websocket.send(json.dumps({'type': 'message', 'message': []}))
assert json.loads(await websocket.receive()) == {
'type': 'error',
'message': 'Unauthorized',
}
finally:
access.membership.role = original_role
adapter.handle_websocket_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_dashboard_history_requires_runtime_permission(self, quart_test_client, fake_pipeline_app):
access = fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.return_value
original_role = access.membership.role
access.membership.role = 'viewer'
try:
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/ws/messages/person',
headers={
'Authorization': 'Bearer test_token',
'X-Workspace-Id': 'workspace-test',
},
)
finally:
access.membership.role = original_role
assert response.status_code == 403
@@ -0,0 +1,272 @@
"""Security regression tests for plugin configuration HTTP responses."""
from __future__ import annotations
import copy
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, call
import pytest
import quart
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
RAW_CONFIG = {
'apiKey': 'api-secret',
'nested': {
'headers': {'Authorization': 'Bearer nested-secret', 'Accept': 'application/json'},
'refresh_token': 'refresh-secret',
'public_key': 'public-material',
'tokenizer': 'not-a-secret',
},
'credentials': {'username': 'service-user', 'password': 'service-password'},
'secret_list': ['first-secret', {'value': 'second-secret'}],
'empty_secret': '',
'enabled': True,
}
def _access(account_uuid: str):
roles = {
'viewer-account': 'viewer',
'operator-account': 'operator',
'manager-account': 'developer',
}
return SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(
uuid=f'membership-{account_uuid}',
role=roles[account_uuid],
projection_revision=1,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
@pytest.fixture(scope='module')
def plugin_module():
"""Import the plugin router without following the core HTTP cycle."""
from tests.utils.import_isolation import MockLifecycleControlScope, isolated_sys_modules
class FakeMinimalApplication:
pass
mock_app = Mock(Application=FakeMinimalApplication)
mock_entities = Mock(LifecycleControlScope=MockLifecycleControlScope)
clear = [
'langbot.pkg.core.taskmgr',
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.plugins',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.plugins as plugins
yield plugins
@pytest.fixture
async def plugin_security_api(plugin_module):
viewer = SimpleNamespace(uuid='viewer-account', user='viewer@example.com')
operator = SimpleNamespace(uuid='operator-account', user='operator@example.com')
manager = SimpleNamespace(uuid='manager-account', user='manager@example.com')
accounts = {
'viewer-token': viewer,
'operator-token': operator,
'manager-token': manager,
}
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
)
application.apikey_service.verify_api_key = AsyncMock(return_value=False)
application.instance_config.data = {
'plugin': {'display_plugin_debug_url': 'http://localhost:5401'},
'system': {'limitation': {}},
}
raw_plugin = {
'author': 'example',
'name': 'secure-plugin',
'plugin_config': RAW_CONFIG,
'debug': {'plugin_debug_key': 'list-debug-secret'},
}
application.plugin_connector.require_workspace_context = AsyncMock()
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
application.plugin_connector.set_plugin_config = AsyncMock()
persistence_result = Mock()
persistence_result.scalar_one_or_none.return_value = RAW_CONFIG
application.persistence_mgr.execute_async = AsyncMock(return_value=persistence_result)
application.persistence_mgr.tenant_uow = None
quart_app = quart.Quart(__name__)
router = plugin_module.PluginsRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client(), raw_plugin
def _headers(token: str) -> dict[str, str]:
return {
'Authorization': f'Bearer {token}',
'X-Workspace-Id': WORKSPACE_UUID,
}
def test_recursive_redaction_preserves_structure_without_mutating_input(plugin_module):
redacted = plugin_module.redact_plugin_secrets(RAW_CONFIG)
assert redacted['apiKey'] == '***'
assert redacted['nested']['headers'] == {
'Authorization': '***',
'Accept': 'application/json',
}
assert redacted['nested']['refresh_token'] == '***'
assert redacted['nested']['public_key'] == 'public-material'
assert redacted['nested']['tokenizer'] == 'not-a-secret'
assert redacted['credentials'] == {'username': '***', 'password': '***'}
assert redacted['secret_list'] == ['***', {'value': '***'}]
assert redacted['empty_secret'] == ''
assert redacted['enabled'] is True
assert RAW_CONFIG['apiKey'] == 'api-secret'
assert RAW_CONFIG['nested']['headers']['Authorization'] == 'Bearer nested-secret'
with pytest.raises(ValueError, match='no existing value'):
plugin_module.restore_plugin_secret_placeholders({'api_key': '***'}, {})
@pytest.mark.asyncio
async def test_viewer_plugin_reads_are_recursively_redacted(plugin_security_api):
application, client, raw_plugin = plugin_security_api
list_response = await client.get('/api/v1/plugins', headers=_headers('viewer-token'))
detail_response = await client.get(
'/api/v1/plugins/example/secure-plugin',
headers=_headers('viewer-token'),
)
config_response = await client.get(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('viewer-token'),
)
assert list_response.status_code == 200
assert detail_response.status_code == 200
assert config_response.status_code == 200
listed_plugin = (await list_response.get_json())['data']['plugins'][0]
detailed_plugin = (await detail_response.get_json())['data']['plugin']
config = (await config_response.get_json())['data']['config']
for plugin in (listed_plugin, detailed_plugin):
assert plugin['plugin_config']['apiKey'] == '***'
assert plugin['debug']['plugin_debug_key'] == '***'
assert config['apiKey'] == '***'
assert config['nested']['headers']['Authorization'] == '***'
assert raw_plugin['plugin_config']['apiKey'] == 'api-secret'
application.plugin_connector.require_workspace_context.assert_awaited()
@pytest.mark.asyncio
async def test_manager_read_is_redacted_but_write_preserves_or_replaces_secrets(
plugin_security_api,
plugin_module,
):
application, client, _ = plugin_security_api
read_response = await client.get(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('manager-token'),
)
masked_update = plugin_module.redact_plugin_secrets(RAW_CONFIG)
masked_update['enabled'] = False
preserved_write = await client.put(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('manager-token'),
json=masked_update,
)
replacement = copy.deepcopy(RAW_CONFIG)
replacement['apiKey'] = 'replacement-secret'
replaced_write = await client.put(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('manager-token'),
json=replacement,
)
preserved = copy.deepcopy(RAW_CONFIG)
preserved['enabled'] = False
assert read_response.status_code == 200
assert (await read_response.get_json())['data']['config']['apiKey'] == '***'
assert preserved_write.status_code == 200
assert replaced_write.status_code == 200
assert application.plugin_connector.set_plugin_config.await_args_list == [
call('example', 'secure-plugin', preserved),
call('example', 'secure-plugin', replacement),
]
@pytest.mark.asyncio
async def test_debug_key_requires_resource_manage_permission(plugin_security_api):
application, client, _ = plugin_security_api
viewer_denied = await client.get('/api/v1/plugins/debug-info', headers=_headers('viewer-token'))
operator_denied = await client.get('/api/v1/plugins/debug-info', headers=_headers('operator-token'))
application.plugin_connector.get_debug_info.assert_not_awaited()
allowed = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
assert viewer_denied.status_code == 403
assert operator_denied.status_code == 403
assert allowed.status_code == 200
assert (await allowed.get_json())['data'] == {
'debug_url': 'http://localhost:5401',
'plugin_debug_key': 'runtime-debug-secret',
}
application.plugin_connector.get_debug_info.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
application, client, _ = plugin_security_api
response = await client.get(
'/api/v1/plugins/example/secure-plugin/logs',
headers=_headers('viewer-token'),
)
assert response.status_code == 403
assert (await response.get_json())['code'] == 'permission_denied'
application.plugin_connector.get_plugin_logs.assert_not_awaited()
@pytest.mark.asyncio
async def test_github_install_rejects_internal_asset_url_before_task_creation(
plugin_security_api,
):
application, client, _ = plugin_security_api
response = await client.post(
'/api/v1/plugins/install/github',
headers=_headers('manager-token'),
json={
'asset_url': 'http://169.254.169.254/latest/meta-data',
'owner': 'langbot-app',
'repo': 'demo-plugin',
'release_tag': 'v1.0.0',
},
)
assert response.status_code == 400
assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg']
application.task_mgr.create_user_task.assert_not_called()
+17 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -66,10 +67,25 @@ def fake_provider_app():
)
# Auth services
account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='workspace-test'),
membership=SimpleNamespace(
uuid='membership-test',
role='owner',
projection_revision=0,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
@@ -0,0 +1,79 @@
"""Skills API behavior when a workspace plan has no managed sandbox."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
from langbot.pkg.cloud.entitlements import (
EntitlementFeatureUnavailableError,
EntitlementUnavailableError,
)
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@pytest.fixture
async def skills_api():
account = SimpleNamespace(uuid='owner-account', user='owner@example.com')
access = SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.skill_service.list_skills = AsyncMock(
side_effect=EntitlementFeatureUnavailableError(
'managed_sandbox',
entitlement_revision=1,
)
)
quart_app = quart.Quart(__name__)
router = SkillsRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
@pytest.mark.asyncio
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
application, client = skills_api
response = await client.get(
'/api/v1/skills',
headers={
'Authorization': 'Bearer owner-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data'] == {'skills': []}
application.skill_service.list_skills.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
application, client = skills_api
application.skill_service.list_skills.side_effect = EntitlementUnavailableError(
'Workspace entitlement revision rolled back'
)
response = await client.get(
'/api/v1/skills',
headers={
'Authorization': 'Bearer owner-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert response.status_code == 500
+43 -2
View File
@@ -86,7 +86,7 @@ def fake_api_app():
'api': {'port': 5300},
'plugin': {'enable_marketplace': True},
'space': {'url': 'https://space.langbot.app'},
'system': {'allow_modify_login_info': True, 'limitation': {}},
'system': {'allow_modify_login_info': True, 'recovery_key': 'recovery-secret', 'limitation': {}},
}
)
@@ -160,7 +160,7 @@ class TestHealthEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data == {'code': 0, 'msg': 'ok'}
assert data == {'code': 0, 'msg': 'ok', 'resources': {}}
@pytest.mark.asyncio
async def test_healthz_no_auth_required(self, quart_test_client):
@@ -288,6 +288,47 @@ class TestUserInitEndpoint:
assert data['msg'] == 'ok'
assert data['data']['initialized'] is False
@pytest.mark.asyncio
async def test_account_info_exposes_instance_capabilities_not_first_account(self, quart_test_client, fake_api_app):
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_login_capabilities = AsyncMock(
return_value={'password_login_enabled': True, 'space_login_enabled': False}
)
fake_api_app.user_service.get_first_user = AsyncMock(
side_effect=AssertionError('public login bootstrap must not inspect an account')
)
response = await quart_test_client.get('/api/v1/user/account-info')
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'password_login_enabled': True,
'space_login_enabled': False,
}
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
fake_api_app.user_service.get_first_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_user_by_email.return_value = Mock(user='member@example.com')
fake_api_app.user_service.reset_password = AsyncMock()
monkeypatch.setattr('langbot.pkg.api.http.controller.groups.user.asyncio.sleep', AsyncMock())
response = await quart_test_client.post(
'/api/v1/user/reset-password',
json={
'user': 'member@example.com',
'recovery_key': 'recovery-secret',
'new_password': 'new-member-password',
},
)
assert response.status_code == 200
fake_api_app.user_service.reset_password.assert_awaited_once_with('member@example.com', 'new-member-password')
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRealImports:
@@ -0,0 +1,338 @@
"""Security tests for the LangBot-to-Space OAuth redirect boundary."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from urllib.parse import parse_qs, urlsplit
import pytest
import quart
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@pytest.fixture
async def space_oauth_api():
account = SimpleNamespace(uuid='account-a', user='owner@example.com')
access = SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(uuid='member-a', role='owner', projection_revision=1),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = None
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock(
side_effect=lambda purpose, **_: f'opaque-{purpose}-state'
)
local_account = SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='local',
)
bound_account = SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='space',
)
application.user_service.consume_space_oauth_state = AsyncMock(
side_effect=lambda state, purpose: (
local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None
)
)
application.user_service.consume_space_oauth_state_details = AsyncMock(
return_value=SimpleNamespace(launch_workspace_uuid=None)
)
application.user_service.bind_space_account = AsyncMock(return_value=bound_account)
application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token')
application.user_service.get_user_by_uuid = AsyncMock(return_value=bound_account)
application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account))
application.user_service.verify_jwt_token = AsyncMock()
application.space_launch_service.consume_assertion = AsyncMock(
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
)
application.space_service.exchange_oauth_code = AsyncMock(
return_value={
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
}
)
application.instance_config.data = {
'api': {'webui_url': 'http://localhost'},
'system': {'allow_modify_login_info': True},
}
quart_app = quart.Quart(__name__)
router = UserRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
@pytest.mark.asyncio
async def test_public_login_state_is_server_issued(space_oauth_api):
application, client = space_oauth_api
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost/auth/space/callback'},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
@pytest.mark.asyncio
async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oauth_api):
application, client = space_oauth_api
application.deployment.multi_workspace_enabled = True
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'launch_workspace_uuid': WORKSPACE_UUID,
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
application.user_service.issue_space_oauth_state.assert_awaited_once_with(
'login',
launch_workspace_uuid=WORKSPACE_UUID,
)
@pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'state': 'jwt.must-not-be-used',
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 1
application.space_service.get_oauth_authorize_url.assert_not_called()
@pytest.mark.asyncio
async def test_bind_state_is_account_bound_and_requires_authentication(space_oauth_api):
application, client = space_oauth_api
path = '/api/v1/user/space/bind-authorize-url'
query = {'redirect_uri': 'http://localhost/auth/space/callback?mode=bind'}
unauthorized = await client.get(path, query_string=query, headers={'Origin': 'http://localhost'})
response = await client.get(
path,
query_string=query,
headers={
'Origin': 'http://localhost',
'Authorization': 'Bearer user-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert unauthorized.status_code == 401
assert response.status_code == 200
application.user_service.issue_space_oauth_state.assert_awaited_once_with('bind', account_uuid='account-a')
@pytest.mark.asyncio
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
_, client = space_oauth_api
wrong_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'http://localhost'},
)
wrong_path = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost/arbitrary'},
headers={'Origin': 'http://localhost'},
)
forged_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'https://evil.example'},
)
forged_host = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Host': 'evil.example'},
)
assert (await wrong_origin.get_json())['code'] == 1
assert (await wrong_path.get_json())['code'] == 1
assert (await forged_origin.get_json())['code'] == 1
assert (await forged_host.get_json())['code'] == 1
@pytest.mark.asyncio
async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'},
headers={'Origin': 'https://irrelevant.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {
'webui_url': '',
'webhook_prefix': 'https://langbot.example/base/path',
}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'},
headers={'Host': 'attacker.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
)
assert (await missing.get_json())['code'] == 1
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
@pytest.mark.asyncio
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state_details.reset_mock()
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['token'] == 'space-login-token'
assert data['workspace_uuid'] == WORKSPACE_UUID
application.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
'account-a',
WORKSPACE_UUID,
)
@pytest.mark.asyncio
async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
application, client = space_oauth_api
application.user_service.get_workspace_owner = AsyncMock(
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid='space-owner')
)
application.space_service.get_credits = AsyncMock(return_value=25000)
response = await client.get(
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID},
)
assert response.status_code == 200
assert (await response.get_json())['data'] == {
'credits': 25000,
'owner_space_bound': True,
'is_workspace_owner': True,
}
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
@pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.user_service.consume_space_oauth_state.side_effect = [
ValueError('invalid state'),
SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='local',
),
]
rejected = await client.post(
'/api/v1/user/bind-space',
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
)
response = await client.post(
'/api/v1/user/bind-space',
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
)
assert rejected.status_code == 401
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
@pytest.mark.asyncio
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.space_service.exchange_oauth_code.reset_mock()
response = await client.post(
'/api/v1/user/space/callback',
json={
'state': 'space-generated-state-is-not-oauth-state',
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['token'] == 'rotated-account-token'
assert data['workspace_uuid'] == WORKSPACE_UUID
application.space_launch_service.consume_assertion.assert_awaited_once_with(
'signed-launch-token',
expected_workspace_uuid=WORKSPACE_UUID,
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
+616
View File
@@ -0,0 +1,616 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import sqlalchemy
import jwt
from quart import Quart
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.controller.groups.workspaces import (
InvitationsRouterGroup,
WorkspacesRouterGroup,
)
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
from langbot.pkg.api.http.controller.groups.apikeys import ApiKeysRouterGroup
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import (
Workspace,
WorkspaceExecutionState,
WorkspaceInvitation,
WorkspaceMembership,
)
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.service import WorkspaceService
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
@pytest.fixture
async def workspace_api(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-api.db"}')
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
application = SimpleNamespace()
application.persistence_mgr = PersistenceManager(application)
application.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
application.instance_config = SimpleNamespace(
data={
'system': {
'jwt': {'secret': 'workspace-api-secret', 'expire': 3600},
'allow_modify_login_info': True,
},
'api': {'global_api_key': '', 'webui_url': 'https://langbot.example'},
}
)
application.logger = logging.getLogger('workspace-api-test')
application.workspace_service = WorkspaceService(
application,
instance_uuid='instance-workspace-api',
)
await application.workspace_service.ensure_singleton_workspace()
application.workspace_collaboration_service = WorkspaceCollaborationService(
application,
application.workspace_service,
)
application.user_service = UserService(application)
application.apikey_service = ApiKeyService(application)
quart_app = Quart(__name__)
await WorkspacesRouterGroup(application, quart_app).initialize()
await InvitationsRouterGroup(application, quart_app).initialize()
await ApiKeysRouterGroup(application, quart_app).initialize()
await UserRouterGroup(application, quart_app).initialize()
await SystemRouterGroup(application, quart_app).initialize()
client = quart_app.test_client()
init_response = await client.post(
'/api/v1/user/init',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert init_response.status_code == 200
auth_response = await client.post(
'/api/v1/user/auth',
json={'user': 'owner@example.com', 'password': 'owner-password'},
)
assert auth_response.status_code == 200
owner_token = (await auth_response.get_json())['data']['token']
yield application, client, engine, owner_token
await engine.dispose()
def _auth(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
headers = {'Authorization': f'Bearer {token}'}
if workspace_uuid is not None:
headers['X-Workspace-Id'] = workspace_uuid
return headers
async def test_account_bootstrap_uses_the_account_resolved_from_the_token(workspace_api):
application, client, _, owner_token = workspace_api
account = await application.user_service.get_authenticated_account(owner_token)
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.get_user_by_email = AsyncMock(return_value=None)
response = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
assert response.status_code == 200
application.user_service.get_user_by_email.assert_not_awaited()
async def test_fresh_sqlite_login_returns_current_workspace_and_user_info(workspace_api):
_, client, _, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert current_response.status_code == 200
current = (await current_response.get_json())['data']
assert current['workspace']['uuid']
assert current['membership']['email'] == 'owner@example.com'
assert current['membership']['role'] == 'owner'
info_response = await client.get('/api/v1/user/info', headers=_auth(owner_token))
assert info_response.status_code == 200
info = (await info_response.get_json())['data']
assert info['account_uuid'] == current['membership']['account_uuid']
assert info['user'] == 'owner@example.com'
async def test_user_info_uses_the_account_resolved_from_the_token(workspace_api):
application, client, _, owner_token = workspace_api
account = await application.user_service.get_authenticated_account(owner_token)
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.get_user_by_email = AsyncMock(return_value=None)
response = await client.get('/api/v1/user/info', headers=_auth(owner_token))
assert response.status_code == 200
info = (await response.get_json())['data']
assert info['account_uuid'] == account.uuid
assert info['user'] == account.user
application.user_service.get_user_by_email.assert_not_awaited()
async def test_authenticated_system_info_reads_workspace_wizard_metadata(workspace_api):
application, client, _, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
progress = {'step': 3, 'selected_adapter': 'telegram'}
await application.persistence_mgr.execute_async(
sqlalchemy.insert(WorkspaceMetadata),
[
{
'workspace_uuid': workspace_uuid,
'key': 'wizard_status',
'value': 'completed',
},
{
'workspace_uuid': workspace_uuid,
'key': 'wizard_progress',
'value': json.dumps(progress),
},
],
)
response = await client.get(
'/api/v1/system/info',
headers=_auth(owner_token, workspace_uuid),
)
assert response.status_code == 200
data = (await response.get_json())['data']
assert data['wizard_status'] == 'completed'
assert data['wizard_progress'] == progress
async def test_owner_invites_second_account_and_secret_is_not_persisted(workspace_api):
application, client, engine, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert current_response.status_code == 200
current = (await current_response.get_json())['data']
workspace_uuid = current['workspace']['uuid']
assert current['membership']['role'] == 'owner'
assert 'member.invite' in current['permissions']
invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(owner_token, workspace_uuid),
json={'email': 'member@example.com', 'role': 'viewer'},
)
assert invite_response.status_code == 200
invite_data = (await invite_response.get_json())['data']
invitation_token = invite_data['token']
assert invitation_token.startswith('lbi_')
assert invite_data['link'] == f'https://langbot.example/invitations/accept#token={invitation_token}'
assert invite_data['delivery'] == {'status': 'link_only', 'provider': None}
assert 'token_hash' not in invite_data['invitation']
async with engine.connect() as connection:
persisted_token_hash = await connection.scalar(
sqlalchemy.select(WorkspaceInvitation.token_hash).where(
WorkspaceInvitation.uuid == invite_data['invitation']['uuid']
)
)
assert persisted_token_hash is not None
assert persisted_token_hash != invitation_token
inspect_response = await client.post(
'/api/v1/invitations/inspect',
json={'token': invitation_token},
)
assert inspect_response.status_code == 200
inspected = (await inspect_response.get_json())['data']
assert inspected['workspace']['uuid'] == workspace_uuid
assert inspected['invitation']['normalized_email'] == 'member@example.com'
accept_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': invitation_token,
'registration': {
'email': 'member@example.com',
'password': 'member-password',
},
},
)
assert accept_response.status_code == 200
member_registration = (await accept_response.get_json())['data']
assert member_registration == {'workspace_uuid': workspace_uuid, 'login_required': True}
member_login_response = await client.post(
'/api/v1/user/auth',
json={'user': 'member@example.com', 'password': 'member-password'},
)
assert member_login_response.status_code == 200
member_token = (await member_login_response.get_json())['data']['token']
reused_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': invitation_token,
'registration': {
'email': 'member@example.com',
'password': 'member-password',
},
},
)
assert reused_response.status_code == 400
assert (await reused_response.get_json())['code'] == 'invitation_used'
member_current_response = await client.get(
'/api/v1/workspaces/current',
headers=_auth(member_token, workspace_uuid),
)
assert member_current_response.status_code == 200
member_current = (await member_current_response.get_json())['data']
assert member_current['membership']['role'] == 'viewer'
assert 'member.invite' not in member_current['permissions']
forbidden_invite = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(member_token, workspace_uuid),
json={'email': 'third@example.com', 'role': 'viewer'},
)
assert forbidden_invite.status_code == 403
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
_, client, _, owner_token = workspace_api
response = await client.post(
'/api/v1/invitations/accept',
headers={'Authorization': f'Bearer {owner_token}'},
json={'token': 'lbi_pending-invitation'},
)
assert response.status_code == 409
assert (await response.get_json())['code'] == 'invitation_logout_required'
async def test_invalid_bearer_on_cloud_invitation_is_authentication_failure(workspace_api):
application, client, _, _ = workspace_api
application.deployment = SimpleNamespace(mode='cloud')
response = await client.post(
'/api/v1/invitations/accept',
headers={'Authorization': 'Bearer definitely-not-a-jwt'},
json={'token': 'lbi_not-a-real-invitation'},
)
assert response.status_code == 401
assert await response.get_json() == {
'code': 'invalid_authentication',
'msg': 'Invalid authentication credentials',
}
async def test_workspace_selector_and_path_cannot_escape_membership(workspace_api):
_, client, _, owner_token = workspace_api
unknown_uuid = '00000000-0000-0000-0000-000000000099'
selector_response = await client.get(
'/api/v1/workspaces/current',
headers=_auth(owner_token, unknown_uuid),
)
assert selector_response.status_code == 404
assert (await selector_response.get_json())['code'] == 'resource_not_found'
path_response = await client.get(
f'/api/v1/workspaces/{unknown_uuid}',
headers=_auth(owner_token),
)
assert path_response.status_code == 404
assert (await path_response.get_json())['code'] == 'resource_not_found'
async def test_oss_rejects_second_workspace(workspace_api):
_, client, _, owner_token = workspace_api
response = await client.post('/api/v1/workspaces', headers=_auth(owner_token), json={'name': 'Second'})
assert response.status_code == 403
assert (await response.get_json())['code'] == 'edition_limit'
async def test_jwt_uses_account_uuid_and_disabled_account_is_rejected(workspace_api):
_, client, engine, owner_token = workspace_api
payload = jwt.decode(
owner_token,
'workspace-api-secret',
algorithms=['HS256'],
audience='langbot-instance:instance-workspace-api',
issuer='langbot-core',
)
assert payload['sub']
assert payload['sub'] != payload['user']
async with engine.begin() as connection:
await connection.execute(sqlalchemy.update(User).where(User.uuid == payload['sub']).values(status='disabled'))
response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert response.status_code == 401
assert (await response.get_json())['code'] == 'invalid_authentication'
async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspace_api):
application, client, _engine, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
create_response = await client.post(
'/api/v1/apikeys',
headers=_auth(owner_token, workspace_uuid),
json={'name': 'E2E automation', 'scopes': ['resource.view']},
)
assert create_response.status_code == 200
created = (await create_response.get_json())['data']['key']
assert created['key'].startswith('lbk_')
assert created['secret_available'] is True
assert 'key_hash' not in created
list_response = await client.get('/api/v1/apikeys', headers=_auth(owner_token, workspace_uuid))
listed = (await list_response.get_json())['data']['keys']
assert len(listed) == 1
assert 'key' not in listed[0]
assert 'key_hash' not in listed[0]
assert listed[0]['secret_available'] is False
identity = await application.apikey_service.authenticate_api_key(created['key'])
assert identity is not None
assert identity.workspace_uuid == workspace_uuid
assert identity.permissions == frozenset({'resource.view'})
invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(owner_token, workspace_uuid),
json={'email': 'viewer@example.com', 'role': 'viewer'},
)
invitation_token = (await invite_response.get_json())['data']['token']
accept_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': invitation_token,
'registration': {'email': 'viewer@example.com', 'password': 'viewer-password'},
},
)
assert accept_response.status_code == 200
assert (await accept_response.get_json())['data']['login_required'] is True
login_response = await client.post(
'/api/v1/user/auth',
json={'user': 'viewer@example.com', 'password': 'viewer-password'},
)
assert login_response.status_code == 200
viewer_token = (await login_response.get_json())['data']['token']
forbidden = await client.post(
'/api/v1/apikeys',
headers=_auth(viewer_token, workspace_uuid),
json={'name': 'forbidden'},
)
assert forbidden.status_code == 403
assert (await forbidden.get_json())['code'] == 'permission_denied'
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
workspace_api,
):
application, client, engine, owner_token = workspace_api
application.deployment = SimpleNamespace(mode='cloud')
owner_uuid = jwt.decode(
owner_token,
'workspace-api-secret',
algorithms=['HS256'],
audience='langbot-instance:instance-workspace-api',
issuer='langbot-core',
)['sub']
cloud_workspace_uuid = '00000000-0000-0000-0000-000000000777'
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.insert(Workspace).values(
uuid=cloud_workspace_uuid,
instance_uuid='instance-workspace-api',
name='Cloud Team',
slug='cloud-team',
type='team',
status='active',
source='cloud_projection',
projection_revision=12,
)
)
await connection.execute(
sqlalchemy.insert(WorkspaceExecutionState).values(
workspace_uuid=cloud_workspace_uuid,
instance_uuid='instance-workspace-api',
active_generation=12,
state='active',
write_fenced=False,
source='cloud',
desired_state_revision=12,
)
)
await connection.execute(
sqlalchemy.insert(WorkspaceMembership).values(
uuid='00000000-0000-0000-0000-000000000778',
workspace_uuid=cloud_workspace_uuid,
account_uuid=owner_uuid,
role='owner',
status='active',
projection_revision=12,
)
)
policy = CloudWorkspacePolicy()
application.workspace_service.policy = policy
application.workspace_collaboration_service.policy = policy
with pytest.raises(ControlPlaneDirectoryRequiredError):
await application.user_service.create_initial_account(
'forbidden-cloud-local@example.com',
'password',
)
omitted = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert omitted.status_code == 404
refreshed_token = await client.get('/api/v1/user/check-token', headers=_auth(owner_token))
assert refreshed_token.status_code == 200
assert (await refreshed_token.get_json())['data']['token']
bootstrap_response = await client.get(
'/api/v1/workspaces/bootstrap',
headers=_auth(owner_token),
)
assert bootstrap_response.status_code == 200
bootstrap = (await bootstrap_response.get_json())['data']
singleton_uuid = (await application.workspace_service.get_singleton_workspace()).uuid
workspace_uuids = [item['workspace']['uuid'] for item in bootstrap['workspaces']]
assert set(workspace_uuids) == {singleton_uuid, cloud_workspace_uuid}
repeated = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
assert [item['workspace']['uuid'] for item in (await repeated.get_json())['data']['workspaces']] == workspace_uuids
by_uuid = {item['workspace']['uuid']: item for item in bootstrap['workspaces']}
assert by_uuid[singleton_uuid]['membership']['account_uuid'] == owner_uuid
assert by_uuid[singleton_uuid]['membership']['email'] == 'owner@example.com'
assert by_uuid[singleton_uuid]['permissions']
assert by_uuid[cloud_workspace_uuid]['placement_generation'] == 12
list_response = await client.get(
'/api/v1/workspaces',
headers=_auth(owner_token, singleton_uuid),
)
assert list_response.status_code == 200
assert {workspace['uuid'] for workspace in (await list_response.get_json())['data']['workspaces']} == {
singleton_uuid,
cloud_workspace_uuid,
}
class PlanResolver:
async def resolve(self, workspace_uuid: str, *, minimum_revision: int = 0):
from langbot.pkg.cloud.entitlements import EntitlementSnapshot
assert workspace_uuid == cloud_workspace_uuid
return EntitlementSnapshot(
instance_uuid='instance-workspace-api',
workspace_uuid=workspace_uuid,
entitlement_revision=max(12, minimum_revision),
status='active',
not_before=1,
expires_at=4102444800,
plan_name='free',
)
application.entitlement_resolver = PlanResolver()
bootstrap_with_plans = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
bootstrap_by_uuid = {
item['workspace']['uuid']: item for item in (await bootstrap_with_plans.get_json())['data']['workspaces']
}
assert bootstrap_by_uuid[cloud_workspace_uuid]['plan_name'] == 'free'
assert bootstrap_by_uuid[singleton_uuid]['plan_name'] is None
current_response = await client.get(
'/api/v1/workspaces/current',
headers=_auth(owner_token, cloud_workspace_uuid),
)
assert current_response.status_code == 200
current = (await current_response.get_json())['data']
assert current['workspace']['uuid'] == cloud_workspace_uuid
assert current['workspace']['source'] == 'cloud_projection'
assert current['placement_generation'] == 12
assert current['plan_name'] == 'free'
create_workspace = await client.post(
'/api/v1/workspaces',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'name': 'Not in Core'},
)
assert create_workspace.status_code == 409
assert (await create_workspace.get_json())['code'] == 'control_plane_required'
create_invitation = await client.post(
f'/api/v1/workspaces/{cloud_workspace_uuid}/invitations',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'email': 'member@example.com', 'role': 'viewer'},
)
assert create_invitation.status_code == 200
created_invitation = (await create_invitation.get_json())['data']
assert created_invitation['invitation']['workspace_uuid'] == cloud_workspace_uuid
assert created_invitation['link'].startswith('https://langbot.example/invitations/accept#token=lbi_')
assert created_invitation['delivery'] == {'status': 'link_only', 'provider': None}
accept_response = await client.post(
'/api/v1/invitations/accept',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'token': created_invitation['token']},
)
assert accept_response.status_code == 400
assert (await accept_response.get_json())['code'] == 'invitation_email_mismatch'
registration_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': created_invitation['token'],
'registration': {'email': 'member@example.com', 'password': 'member-password'},
},
)
assert registration_response.status_code == 401
assert (await registration_response.get_json())['code'] == 'account_exists_login_required'
async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
application, client, engine, owner_token = workspace_api
foreign_workspace_uuid = '00000000-0000-0000-0000-000000000880'
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.insert(Workspace).values(
uuid=foreign_workspace_uuid,
instance_uuid='instance-workspace-api',
name='Foreign Team',
slug='foreign-team',
type='team',
status='active',
source='cloud_projection',
projection_revision=1,
)
)
await connection.execute(
sqlalchemy.insert(WorkspaceExecutionState).values(
workspace_uuid=foreign_workspace_uuid,
instance_uuid='instance-workspace-api',
active_generation=1,
state='active',
write_fenced=False,
source='cloud',
desired_state_revision=1,
)
)
policy = CloudWorkspacePolicy()
application.workspace_service.policy = policy
application.workspace_collaboration_service.policy = policy
response = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
assert response.status_code == 200
workspace_uuids = {item['workspace']['uuid'] for item in (await response.get_json())['data']['workspaces']}
assert foreign_workspace_uuid not in workspace_uuids
current = await client.get(
'/api/v1/workspaces/current',
headers=_auth(owner_token, foreign_workspace_uuid),
)
assert current.status_code == 404
assert (await current.get_json())['code'] == 'resource_not_found'