mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
183 lines
7.5 KiB
Python
183 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import dataclasses
|
|
import inspect
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import lark_oapi
|
|
import pytest
|
|
from pydantic import TypeAdapter
|
|
|
|
from langbot.pkg.api.application.api import ApplicationAPI
|
|
from langbot.pkg.api.application.contracts import ApplicationAPIException
|
|
from langbot.pkg.api.application.platform_setup import operations
|
|
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
|
from langbot.pkg.core.errors import TaskCapacityError
|
|
from langbot.pkg.platform.credential_setup import CredentialSetupError, _bind_session_scope, get_credential_setup
|
|
|
|
|
|
def context(**changes):
|
|
return RequestContext(
|
|
instance_uuid='instance-a',
|
|
workspace=WorkspaceContext('workspace-a', 'membership-a', 'developer', frozenset({'resource.manage'})),
|
|
placement_generation=1,
|
|
request_id='request-a',
|
|
auth_type='user-token',
|
|
principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
|
|
**changes,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ap(monkeypatch):
|
|
@contextlib.asynccontextmanager
|
|
async def tenant_scope(workspace):
|
|
yield
|
|
|
|
def create_user_task(coro, **kwargs):
|
|
return SimpleNamespace(task=asyncio.create_task(coro))
|
|
|
|
application = SimpleNamespace(
|
|
task_mgr=SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)),
|
|
workspace_service=SimpleNamespace(
|
|
get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a'))
|
|
),
|
|
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
|
|
)
|
|
monkeypatch.setattr('langbot.pkg.api.application.api.get_workspace_language', AsyncMock(return_value='en_US'))
|
|
monkeypatch.setattr(ApplicationAPI, '_record', AsyncMock())
|
|
return application
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_covers_all_credential_flows_and_checks_permission_before_start(ap):
|
|
api = ApplicationAPI(ap, operations=operations())
|
|
assert len(api.operation_catalog()) == 18
|
|
assert {op['permission'] for op in api.operation_catalog()} == {'resource.manage'}
|
|
viewer = dataclasses.replace(
|
|
context(), workspace=WorkspaceContext('workspace-a', 'membership-a', 'viewer', frozenset())
|
|
)
|
|
with pytest.raises(ApplicationAPIException) as exc:
|
|
await api.execute('platform.setup.lark.start', viewer, {})
|
|
assert exc.value.error.code == 'permission_denied'
|
|
ap.task_mgr.create_user_task.assert_not_called()
|
|
assert not hasattr(ap, 'credential_setup_service')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_service_is_shared_with_transport_and_does_not_need_request_globals(ap, monkeypatch):
|
|
release = asyncio.Event()
|
|
completed = asyncio.Event()
|
|
|
|
async def register(*, on_qr_code, source):
|
|
on_qr_code({'url': 'https://example.test/qr'})
|
|
await release.wait()
|
|
completed.set()
|
|
return {'client_id': 'app-a', 'client_secret': 'secret-a'}
|
|
|
|
monkeypatch.setattr(lark_oapi, 'aregister_app', register)
|
|
api = ApplicationAPI(ap, operations=operations())
|
|
ctx = context()
|
|
started = await api.execute('platform.setup.lark.start', ctx, {})
|
|
service = get_credential_setup(ap)
|
|
assert get_credential_setup(ap) is service
|
|
assert await service.lark_status(started['session_id'], ctx) == {'status': 'waiting'}
|
|
release.set()
|
|
await completed.wait()
|
|
result = await api.execute('platform.setup.lark.status', ctx, {'session_id': started['session_id']})
|
|
assert result == {'status': 'success', 'app_id': 'app-a', 'app_secret': 'secret-a'}
|
|
with pytest.raises(CredentialSetupError) as exc:
|
|
await service.lark_status(started['session_id'], ctx)
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_admission_error_is_preserved_as_domain_status(ap):
|
|
ap.task_mgr.create_user_task.side_effect = TaskCapacityError('capacity exhausted')
|
|
with pytest.raises(CredentialSetupError) as exc:
|
|
await get_credential_setup(ap).lark_start(context())
|
|
assert exc.value.status_code == 429
|
|
assert 'Too many active' in str(exc.value)
|
|
|
|
|
|
_CASES = [
|
|
('lark', '_create_app_sessions', {'app_id': 'id', 'app_secret': 'secret'}),
|
|
(
|
|
'weixin',
|
|
'_weixin_login_sessions',
|
|
{
|
|
'qr_data_url': 'data:qr',
|
|
'expire_at': 1.0,
|
|
'token': 'token',
|
|
'base_url': 'https://example.test',
|
|
'account_id': 'id',
|
|
},
|
|
),
|
|
('dingtalk', '_dingtalk_sessions', {'client_id': 'id', 'client_secret': 'secret'}),
|
|
('wecombot', '_wecombot_sessions', {'botid': 'id', 'secret': 'secret'}),
|
|
('qqofficial', '_qqofficial_sessions', {'appid': 'id', 'secret': 'secret', 'user_openid': 'user'}),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('platform,store_name,credentials', _CASES)
|
|
async def test_each_platform_preserves_exact_owner_scoping_and_credentials(ap, platform, store_name, credentials):
|
|
service = get_credential_setup(ap)
|
|
status = getattr(service, f'{platform}_status')
|
|
cancel = getattr(service, f'{platform}_cancel')
|
|
store = inspect.getclosurevars(status).nonlocals[store_name]
|
|
session = {'status': 'success', 'created_at': time.time(), **credentials}
|
|
owner = context()
|
|
_bind_session_scope(session, owner)
|
|
store['session-a'] = session
|
|
foreign = dataclasses.replace(owner, principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-b'))
|
|
for ctx in (
|
|
foreign,
|
|
dataclasses.replace(owner, placement_generation=2),
|
|
dataclasses.replace(owner, workspace=dataclasses.replace(owner.workspace, workspace_uuid='workspace-b')),
|
|
):
|
|
with pytest.raises(CredentialSetupError) as exc:
|
|
await status('session-a', ctx)
|
|
assert exc.value.status_code == 404
|
|
with pytest.raises(CredentialSetupError):
|
|
await cancel('session-a', ctx)
|
|
assert store['session-a'] is session
|
|
result = await status('session-a', owner)
|
|
spec = next(op for op in operations() if op.operation_id == f'platform.setup.{platform}.status')
|
|
assert TypeAdapter(spec.output_type).validate_python(result, strict=True) == result
|
|
assert result == {'status': 'success', **credentials}
|
|
assert 'session-a' not in store
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('platform,store_name,credentials', _CASES)
|
|
async def test_each_platform_cancel_stops_its_owned_task(ap, platform, store_name, credentials):
|
|
service = get_credential_setup(ap)
|
|
status = getattr(service, f'{platform}_status')
|
|
store = inspect.getclosurevars(status).nonlocals[store_name]
|
|
task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock())
|
|
session = {'status': 'waiting', 'created_at': time.time(), 'task': task, **credentials}
|
|
_bind_session_scope(session, context())
|
|
store['session-a'] = session
|
|
assert await getattr(service, f'{platform}_cancel')('session-a', context()) == {}
|
|
task.cancel.assert_called_once_with()
|
|
assert not store
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_cancel_checks_ownership_before_discard(ap):
|
|
from langbot.pkg.api.http.service.slack_setup import SlackSetupError
|
|
|
|
ap.slack_setup_service = SimpleNamespace(
|
|
owned=Mock(side_effect=SlackSetupError('session_not_found')), discard=Mock()
|
|
)
|
|
api = ApplicationAPI(ap, operations=operations())
|
|
with pytest.raises(ApplicationAPIException) as exc:
|
|
await api.execute('platform.setup.slack.cancel', context(), {'session_id': 'foreign-session'})
|
|
assert exc.value.error.status_code == 404
|
|
ap.slack_setup_service.discard.assert_not_called()
|