mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
chore(merge): sync master into dev/4.11.x
This commit is contained in:
@@ -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'},
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user