mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 17:35:19 +08:00
Merge master into dev/4.11.x and preserve plugin runner architecture
Reconcile migration branches without rewriting published revisions; retain additive Codex, monitoring, provider and platform fixes. Keep dynamic runner schemas and Host ownership, restore compatibility regressions, and preserve safe model-test error handling.
This commit is contained in:
@@ -9,7 +9,7 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.factories import FakeApp
|
||||
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
|
||||
assert kwargs['bot_ids'] == ['bot-1']
|
||||
assert kwargs['user_query'] == 'alice'
|
||||
assert kwargs['limit'] == 20
|
||||
assert kwargs['offset'] == 40
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringErrorsEndpoint:
|
||||
@@ -264,13 +280,20 @@ class TestMonitoringAllDataEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_data_success(self, quart_test_client):
|
||||
"""GET /api/v1/monitoring/data returns all data."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
traffic = {'series': [], 'truncated': False}
|
||||
with patch(
|
||||
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
|
||||
new=AsyncMock(return_value=traffic),
|
||||
) as get_traffic:
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
get_traffic.assert_awaited_once()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert 'overview' in data['data']
|
||||
assert data['data']['traffic'] == traffic
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
@@ -278,13 +301,19 @@ class TestMonitoringDetailsEndpoints:
|
||||
"""Tests for detail endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_analysis(self, quart_test_client):
|
||||
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
|
||||
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
|
||||
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
|
||||
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_details(self, quart_test_client):
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||
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]
|
||||
|
||||
|
||||
async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch):
|
||||
"""Exercise generation, reset, and old/new password login without mocked user services."""
|
||||
monkeypatch.setattr(constants, 'instance_id', 'recovery-journey')
|
||||
monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0})
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||
application = SimpleNamespace(
|
||||
logger=logging.getLogger('recovery-password-journey'),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}},
|
||||
'system': {
|
||||
'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600},
|
||||
'recovery_key': '',
|
||||
},
|
||||
},
|
||||
dump_config=AsyncMock(),
|
||||
),
|
||||
)
|
||||
await GenKeysStage().run(application)
|
||||
key = application.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||
persistence = PersistenceManager(application)
|
||||
application.persistence_mgr = persistence
|
||||
try:
|
||||
await persistence.initialize()
|
||||
application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey')
|
||||
application.workspace_collaboration_service = WorkspaceCollaborationService(
|
||||
application, application.workspace_service
|
||||
)
|
||||
application.user_service = UserService(application)
|
||||
quart_app = Quart(__name__)
|
||||
await UserRouterGroup(application, quart_app).initialize()
|
||||
client = quart_app.test_client()
|
||||
|
||||
initial = await client.post(
|
||||
'/api/v1/user/init', json={'user': '[email protected]', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert initial.status_code == 200
|
||||
assert (await initial.get_json())['code'] == 0
|
||||
|
||||
payload = {'user': '[email protected]', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'}
|
||||
wrong = await client.post('/api/v1/user/reset-password', json=payload)
|
||||
assert wrong.status_code == 403
|
||||
unchanged = await client.post(
|
||||
'/api/v1/user/auth', json={'user': '[email protected]', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert (await unchanged.get_json())['code'] == 0
|
||||
|
||||
reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key})
|
||||
assert reset.status_code == 200
|
||||
assert (await reset.get_json())['code'] == 0
|
||||
old_login = await client.post(
|
||||
'/api/v1/user/auth', json={'user': '[email protected]', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert (await old_login.get_json())['code'] != 0
|
||||
new_login = await client.post(
|
||||
'/api/v1/user/auth', json={'user': '[email protected]', 'password': 'RecoveredPass1!'}
|
||||
)
|
||||
new_data = await new_login.get_json()
|
||||
assert new_data['code'] == 0
|
||||
assert new_data['data']['token']
|
||||
finally:
|
||||
await persistence.get_db_engine().dispose()
|
||||
@@ -310,6 +310,8 @@ class TestUserInitEndpoint:
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': False,
|
||||
'passkey_login_enabled': True,
|
||||
'passkey_supported': True,
|
||||
}
|
||||
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
||||
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
||||
@@ -334,6 +336,8 @@ class TestUserInitEndpoint:
|
||||
'invitation_registration_enabled': False,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
'passkey_login_enabled': True,
|
||||
'passkey_supported': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -355,6 +359,8 @@ class TestUserInitEndpoint:
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
'passkey_login_enabled': True,
|
||||
'passkey_supported': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Integration smoke tests for Passkey API endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import FakeApp
|
||||
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')]
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mock_circular_import_chain():
|
||||
class FakeMinimalApplication:
|
||||
pass
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.Application = FakeMinimalApplication
|
||||
|
||||
mock_entities = Mock()
|
||||
mock_entities.LifecycleControlScope = MockLifecycleControlScope
|
||||
|
||||
clear = [
|
||||
'langbot.pkg.api.http.controller.group',
|
||||
'langbot.pkg.api.http.controller.groups',
|
||||
'langbot.pkg.api.http.controller.groups.system',
|
||||
'langbot.pkg.api.http.controller.groups.user',
|
||||
'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.user as _user_group # noqa: E402, F401
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_api_app():
|
||||
app = FakeApp()
|
||||
app.instance_config.data.update(
|
||||
{
|
||||
'api': {'port': 5300},
|
||||
'system': {'allow_modify_login_info': True},
|
||||
}
|
||||
)
|
||||
app.user_service = Mock()
|
||||
app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token'))
|
||||
app.user_service.get_user_by_email = AsyncMock(return_value=Mock())
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def quart_test_client(fake_api_app, http_controller_cls):
|
||||
controller = http_controller_cls(fake_api_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
yield client
|
||||
|
||||
|
||||
class TestPasskeyPublicEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_endpoint(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'localhost'}, 'token_123')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={'origin': 'http://localhost:3000'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['challenge_token'] == 'token_123'
|
||||
assert data['data']['options']['rpId'] == 'localhost'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_verify_missing_payload(self, quart_test_client, fake_api_app):
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/verify',
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] != 0
|
||||
assert 'Missing challenge_token or credential' in data['msg']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_verify_success(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.verify_passkey_authentication = AsyncMock(
|
||||
return_value=('jwt_token_abc', Mock(user='[email protected]'))
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/verify',
|
||||
json={'challenge_token': 'token_123', 'credential': {'id': 'cred_id'}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['token'] == 'jwt_token_abc'
|
||||
assert data['data']['user'] == '[email protected]'
|
||||
|
||||
|
||||
class TestPasskeyProtectedEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_options_requires_auth(self, quart_test_client):
|
||||
response = await quart_test_client.post('/api/v1/user/passkey/register/options', json={})
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passkeys_list_requires_auth(self, quart_test_client):
|
||||
response = await quart_test_client.get('/api/v1/user/passkeys')
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestPasskeyReverseProxyScenarios:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_respects_custom_origin_body_behind_proxy(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'proxy.company.com'}, 'token_proxy')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={'origin': 'https://proxy.company.com:8443'},
|
||||
headers={'Host': '127.0.0.1:5300'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
|
||||
rp_id='proxy.company.com',
|
||||
origin='https://proxy.company.com:8443',
|
||||
email=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_falls_back_to_origin_header(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_header')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={},
|
||||
headers={'Origin': 'https://bot.example.com'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
|
||||
rp_id='bot.example.com',
|
||||
origin='https://bot.example.com',
|
||||
email=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_falls_back_to_referer_header(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_referer')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={},
|
||||
headers={'Referer': 'https://bot.example.com:9000/login'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
|
||||
rp_id='bot.example.com',
|
||||
origin='https://bot.example.com:9000',
|
||||
email=None,
|
||||
)
|
||||
@@ -27,7 +27,8 @@ async def space_oauth_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||
application.directory_projection_service = None
|
||||
application.persistence_mgr = None
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={
|
||||
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||
'cloud_entry': '1',
|
||||
},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||
assert authorize_url.startswith('https://space.example/authorize?state=')
|
||||
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -249,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
||||
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'})
|
||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
json={
|
||||
'code': 'v4_oauth-code',
|
||||
'state': 'opaque-login-state',
|
||||
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||
},
|
||||
)
|
||||
|
||||
assert (await missing.get_json())['code'] == 1
|
||||
@@ -260,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
||||
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',
|
||||
'v4_oauth-code',
|
||||
[WORKSPACE_UUID],
|
||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||
redirect_uri='https://oss.example/auth/space/callback',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'code contract' in payload['msg']
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
calls: list[str] = []
|
||||
application.directory_projection_service = SimpleNamespace(
|
||||
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
|
||||
)
|
||||
application.space_service.exchange_oauth_code.return_value = {
|
||||
'access_token': 'space-access-token',
|
||||
'refresh_token': 'space-refresh-token',
|
||||
'expires_in': 3600,
|
||||
'cloud_workspace_uuid': WORKSPACE_UUID,
|
||||
}
|
||||
|
||||
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
|
||||
|
||||
async def authenticate(*_args):
|
||||
calls.append('authenticate')
|
||||
return 'space-login-token', authenticated_account
|
||||
|
||||
application.user_service.authenticate_space_user.side_effect = authenticate
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert calls == ['reconcile', 'authenticate']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Cloud Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
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': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||
launch_workspace_uuid=WORKSPACE_UUID
|
||||
)
|
||||
application.space_service.exchange_oauth_code.return_value = {
|
||||
'access_token': 'space-access-token',
|
||||
'refresh_token': 'space-refresh-token',
|
||||
'expires_in': 3600,
|
||||
'cloud_workspace_uuid': 'workspace-from-another-flow',
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -276,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -375,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
||||
|
||||
rejected = await client.post(
|
||||
'/api/v1/user/bind-space',
|
||||
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||
json={'code': 'v4_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'},
|
||||
json={'code': 'v4_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('[email protected]', 'oauth-code')
|
||||
application.user_service.bind_space_account.assert_awaited_once_with(
|
||||
'[email protected]',
|
||||
'v4_oauth-code',
|
||||
redirect_uri='http://localhost/auth/space/callback?mode=bind',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -394,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
application, client = space_oauth_api
|
||||
application.user_service.consume_space_oauth_state.reset_mock()
|
||||
application.space_service.exchange_oauth_code.reset_mock()
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
@@ -414,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
)
|
||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_account = SimpleNamespace(
|
||||
uuid='account-a',
|
||||
user='[email protected]',
|
||||
account_type='space',
|
||||
status='active',
|
||||
)
|
||||
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
@@ -22,6 +23,7 @@ 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 import apikey
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
Workspace,
|
||||
@@ -440,6 +442,264 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac
|
||||
assert (await forbidden.get_json())['code'] == 'permission_denied'
|
||||
|
||||
|
||||
async def test_api_key_context_returns_bound_identity_without_workspace_permission(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']
|
||||
|
||||
create_response = await client.post(
|
||||
'/api/v1/apikeys',
|
||||
headers=_auth(owner_token, workspace_uuid),
|
||||
json={'name': 'Context probe', 'scopes': []},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
created = (await create_response.get_json())['data']['key']
|
||||
|
||||
missing_auth = await client.get('/api/v1/system/context')
|
||||
assert missing_auth.status_code == 401
|
||||
|
||||
invalid_auth = await client.get(
|
||||
'/api/v1/system/context',
|
||||
headers={'X-API-Key': 'lbk_invalid'},
|
||||
)
|
||||
assert invalid_auth.status_code == 401
|
||||
|
||||
invalid_capabilities = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={'X-API-Key': 'lbk_invalid'},
|
||||
)
|
||||
assert invalid_capabilities.status_code == 401
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/system/context',
|
||||
headers={
|
||||
'X-API-Key': created['key'],
|
||||
'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data'] == {
|
||||
'instance_uuid': application.workspace_service.instance_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'api_key_id': created['uuid'],
|
||||
'permissions': [],
|
||||
}
|
||||
|
||||
capabilities_response = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={
|
||||
'X-API-Key': created['key'],
|
||||
'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored',
|
||||
},
|
||||
)
|
||||
assert capabilities_response.status_code == 200
|
||||
capabilities = (await capabilities_response.get_json())['data']
|
||||
assert capabilities['schema_version'] == 1
|
||||
assert sorted(capabilities['operations']) == sorted(
|
||||
[
|
||||
'bot.list',
|
||||
'bot.get',
|
||||
'bot.create',
|
||||
'bot.update',
|
||||
'bot.delete',
|
||||
'pipeline.list',
|
||||
'pipeline.get',
|
||||
'pipeline.create',
|
||||
'pipeline.update',
|
||||
'pipeline.delete',
|
||||
'pipeline.copy',
|
||||
'task.list',
|
||||
'task.get',
|
||||
'knowledge_base.list',
|
||||
'knowledge_base.get',
|
||||
'knowledge_base.create',
|
||||
'knowledge_base.update',
|
||||
'knowledge_base.delete',
|
||||
'knowledge_base.file.list',
|
||||
'knowledge_base.file.store',
|
||||
'knowledge_base.file.delete',
|
||||
'knowledge_base.retrieve',
|
||||
'file.document.upload',
|
||||
'plugin.install.github',
|
||||
'plugin.install.marketplace',
|
||||
'plugin.install.local',
|
||||
'plugin.upgrade',
|
||||
'plugin.get',
|
||||
'plugin.list',
|
||||
'plugin.config.get',
|
||||
'plugin.config.update',
|
||||
'plugin.logs',
|
||||
'plugin.delete',
|
||||
'provider.list',
|
||||
'provider.get',
|
||||
'provider.create',
|
||||
'provider.update',
|
||||
'provider.delete',
|
||||
'provider.scan_models',
|
||||
'model.llm.list',
|
||||
'model.llm.get',
|
||||
'model.llm.create',
|
||||
'model.llm.update',
|
||||
'model.llm.delete',
|
||||
'model.llm.test',
|
||||
'model.embedding.list',
|
||||
'model.embedding.get',
|
||||
'model.embedding.create',
|
||||
'model.embedding.update',
|
||||
'model.embedding.delete',
|
||||
'model.embedding.test',
|
||||
'model.rerank.list',
|
||||
'model.rerank.get',
|
||||
'model.rerank.create',
|
||||
'model.rerank.update',
|
||||
'model.rerank.delete',
|
||||
'model.rerank.test',
|
||||
'skill.list',
|
||||
'skill.get',
|
||||
'skill.create',
|
||||
'skill.update',
|
||||
'skill.delete',
|
||||
'skill.files.list',
|
||||
'skill.files.read',
|
||||
'skill.files.write',
|
||||
'skill.preview',
|
||||
'skill.install.github',
|
||||
'skill.install.upload',
|
||||
'mcp_server.list',
|
||||
'mcp_server.get',
|
||||
'mcp_server.create',
|
||||
'mcp_server.update',
|
||||
'mcp_server.delete',
|
||||
'mcp_server.resources',
|
||||
'mcp_server.resource_templates',
|
||||
'mcp_server.resource_read',
|
||||
'mcp_server.logs',
|
||||
'mcp_server.test',
|
||||
]
|
||||
)
|
||||
assert all(item == {'supported': True} for item in capabilities['operations'].values())
|
||||
assert created['key'] not in await capabilities_response.get_data(as_text=True)
|
||||
|
||||
bearer_response = await client.get(
|
||||
'/api/v1/system/context',
|
||||
headers={'Authorization': f'Bearer {created["key"]}'},
|
||||
)
|
||||
assert bearer_response.status_code == 200
|
||||
assert (await bearer_response.get_json())['data']['api_key_id'] == created['uuid']
|
||||
|
||||
jwt_response = await client.get(
|
||||
'/api/v1/system/context',
|
||||
headers={'Authorization': f'Bearer {owner_token}'},
|
||||
)
|
||||
assert jwt_response.status_code == 401
|
||||
|
||||
await application.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey)
|
||||
.where(apikey.ApiKey.uuid == created['uuid'])
|
||||
.values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(seconds=1))
|
||||
)
|
||||
expired_capabilities = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={'X-API-Key': created['key']},
|
||||
)
|
||||
assert expired_capabilities.status_code == 401
|
||||
|
||||
revoke_response = await client.delete(
|
||||
f'/api/v1/apikeys/{created["id"]}',
|
||||
headers=_auth(owner_token, workspace_uuid),
|
||||
)
|
||||
assert revoke_response.status_code == 200
|
||||
|
||||
revoked_response = await client.get(
|
||||
'/api/v1/system/context',
|
||||
headers={'X-API-Key': created['key']},
|
||||
)
|
||||
assert revoked_response.status_code == 401
|
||||
revoked_capabilities = await client.get(
|
||||
'/api/v1/system/capabilities',
|
||||
headers={'X-API-Key': created['key']},
|
||||
)
|
||||
assert revoked_capabilities.status_code == 401
|
||||
|
||||
|
||||
async def test_api_key_can_query_tasks_with_public_contract_and_resource_permission(workspace_api):
|
||||
application, client, _, owner_token = workspace_api
|
||||
task_query = {}
|
||||
task_lookup = {}
|
||||
fake_task = SimpleNamespace(
|
||||
to_public_dict=lambda: {'id': 7, 'status': 'running', 'error': None, 'result': None},
|
||||
to_dict=lambda: {'id': 7, 'runtime': {'state': 'PENDING'}},
|
||||
)
|
||||
|
||||
def get_tasks_dict(*args, **kwargs):
|
||||
task_query.update(kwargs)
|
||||
if kwargs.get('public'):
|
||||
return {'tasks': []}
|
||||
return {'tasks': [], 'id_index': 1}
|
||||
|
||||
def get_task_by_id(*args, **kwargs):
|
||||
task_lookup.update(kwargs)
|
||||
return fake_task if args and args[0] == 7 else None
|
||||
|
||||
application.task_mgr = SimpleNamespace(
|
||||
get_tasks_dict=get_tasks_dict,
|
||||
get_task_by_id=get_task_by_id,
|
||||
)
|
||||
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': 'Task reader', 'scopes': ['resource.view']},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
key = (await create_response.get_json())['data']['key']['key']
|
||||
|
||||
listing = await client.get('/api/v1/system/tasks', headers={'X-API-Key': key})
|
||||
assert listing.status_code == 200
|
||||
assert (await listing.get_json())['data'] == {'tasks': []}
|
||||
assert task_query['instance_uuid'] == application.workspace_service.instance_uuid
|
||||
assert task_query['workspace_uuid'] == workspace_uuid
|
||||
assert task_query['placement_generation'] == 1
|
||||
assert task_query['public'] is True
|
||||
|
||||
bearer_listing = await client.get('/api/v1/system/tasks', headers=_auth(owner_token, workspace_uuid))
|
||||
assert bearer_listing.status_code == 200
|
||||
assert (await bearer_listing.get_json())['data'] == {'tasks': [], 'id_index': 1}
|
||||
|
||||
public_task = await client.get('/api/v1/system/tasks/7', headers={'X-API-Key': key})
|
||||
assert public_task.status_code == 200
|
||||
assert (await public_task.get_json())['data'] == {
|
||||
'id': 7,
|
||||
'status': 'running',
|
||||
'error': None,
|
||||
'result': None,
|
||||
}
|
||||
assert task_lookup == {
|
||||
'instance_uuid': application.workspace_service.instance_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'placement_generation': 1,
|
||||
}
|
||||
|
||||
legacy_task = await client.get('/api/v1/system/tasks/7', headers=_auth(owner_token, workspace_uuid))
|
||||
assert legacy_task.status_code == 200
|
||||
assert (await legacy_task.get_json())['data'] == {'id': 7, 'runtime': {'state': 'PENDING'}}
|
||||
|
||||
missing = await client.get('/api/v1/system/tasks/not-an-id', headers={'X-API-Key': key})
|
||||
assert missing.status_code == 404
|
||||
|
||||
no_permission_response = await client.post(
|
||||
'/api/v1/apikeys',
|
||||
headers=_auth(owner_token, workspace_uuid),
|
||||
json={'name': 'Task denied', 'scopes': []},
|
||||
)
|
||||
assert no_permission_response.status_code == 200
|
||||
no_permission_key = (await no_permission_response.get_json())['data']['key']['key']
|
||||
denied = await client.get('/api/v1/system/tasks', headers={'X-API-Key': no_permission_key})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
||||
workspace_api,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user