mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -14,17 +14,124 @@ Source: src/langbot/pkg/api/http/service/user.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import jwt
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.errors.account import AccountEmailMismatchError
|
||||
from langbot.pkg.api.http.service.user import (
|
||||
ControlPlaneDirectoryRequiredError,
|
||||
UserService,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User
|
||||
from langbot.pkg.entity.errors.account import (
|
||||
AccountEmailMismatchError,
|
||||
SpaceAccountBindingRequiredError,
|
||||
SpaceAccountNotRegisteredError,
|
||||
)
|
||||
from langbot.pkg.utils.bounded_executor import BlockingWorkCapacityError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_password_hashing_rejects_concurrent_waiters() -> None:
|
||||
service = UserService(SimpleNamespace())
|
||||
await service._password_hash_lock.acquire()
|
||||
try:
|
||||
with pytest.raises(
|
||||
BlockingWorkCapacityError,
|
||||
match='Password hashing capacity reached',
|
||||
):
|
||||
await service._hash_password('secret')
|
||||
finally:
|
||||
service._password_hash_lock.release()
|
||||
|
||||
|
||||
class TestSpaceOAuthState:
|
||||
async def test_login_state_is_opaque_single_use(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
|
||||
state = await service.issue_space_oauth_state('login')
|
||||
|
||||
assert state.count('.') == 0
|
||||
assert await service.consume_space_oauth_state(state, 'login') is None
|
||||
with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
|
||||
await service.consume_space_oauth_state(state, 'login')
|
||||
|
||||
async def test_bind_state_resolves_only_bound_active_account(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
account = SimpleNamespace(uuid='account-a', status=AccountStatus.ACTIVE.value)
|
||||
service.get_user_by_uuid = AsyncMock(return_value=account)
|
||||
|
||||
state = await service.issue_space_oauth_state('bind', account_uuid='account-a')
|
||||
|
||||
assert await service.consume_space_oauth_state(state, 'bind') is account
|
||||
service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||
|
||||
async def test_state_purpose_mismatch_is_rejected_and_consumed(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
state = await service.issue_space_oauth_state('login')
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
|
||||
await service.consume_space_oauth_state(state, 'bind')
|
||||
with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
|
||||
await service.consume_space_oauth_state(state, 'login')
|
||||
|
||||
async def test_expired_state_is_rejected(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
state = await service.issue_space_oauth_state('login')
|
||||
digest = service._space_oauth_state_digest(state)
|
||||
purpose, account_uuid, _, launch_workspace_uuid = service._space_oauth_states[digest]
|
||||
service._space_oauth_states[digest] = (purpose, account_uuid, 0, launch_workspace_uuid)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
|
||||
await service.consume_space_oauth_state(state, 'login')
|
||||
|
||||
async def test_login_state_can_carry_launch_workspace_without_changing_normal_return(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
state = await service.issue_space_oauth_state(
|
||||
'login',
|
||||
launch_workspace_uuid='workspace-a',
|
||||
)
|
||||
|
||||
assert await service.consume_space_oauth_state(state, 'login') is None
|
||||
|
||||
state = await service.issue_space_oauth_state(
|
||||
'login',
|
||||
launch_workspace_uuid='workspace-a',
|
||||
)
|
||||
consumed = await service.consume_space_oauth_state_details(state, 'login')
|
||||
assert consumed.account is None
|
||||
assert consumed.launch_workspace_uuid == 'workspace-a'
|
||||
|
||||
async def test_issue_state_does_not_scan_all_live_states(self, monkeypatch):
|
||||
service = UserService(SimpleNamespace())
|
||||
for _ in range(512):
|
||||
await service.issue_space_oauth_state('login')
|
||||
|
||||
class NoGlobalIterationDict(dict):
|
||||
def __iter__(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
def keys(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
def items(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
def values(self):
|
||||
raise AssertionError('OAuth state issuance scanned all live states')
|
||||
|
||||
guarded_states = NoGlobalIterationDict(service._space_oauth_states)
|
||||
monkeypatch.setattr(service, '_space_oauth_states', guarded_states)
|
||||
|
||||
state = await service.issue_space_oauth_state('login')
|
||||
|
||||
assert await service.consume_space_oauth_state(state, 'login') is None
|
||||
assert len(guarded_states) == 512
|
||||
|
||||
|
||||
def _create_mock_user(
|
||||
email: str = 'test@example.com',
|
||||
password: str = 'hashed_password',
|
||||
@@ -34,6 +141,7 @@ def _create_mock_user(
|
||||
"""Helper to create mock User entity."""
|
||||
user = Mock(spec=User)
|
||||
user.user = email
|
||||
user.uuid = f'account-{email}'
|
||||
user.password = password
|
||||
user.account_type = account_type
|
||||
user.space_account_uuid = space_account_uuid
|
||||
@@ -102,6 +210,41 @@ class TestUserServiceIsInitialized:
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestUserServiceGetLoginCapabilities:
|
||||
"""Tests for public login capability discovery."""
|
||||
|
||||
async def test_uses_explicit_identity_discovery_scope(self):
|
||||
discovery_result = Mock()
|
||||
discovery_result.one = Mock(return_value=(1, 2))
|
||||
discovery_session = SimpleNamespace(execute=AsyncMock(return_value=discovery_result))
|
||||
|
||||
class DiscoveryContext:
|
||||
async def __aenter__(self):
|
||||
return SimpleNamespace(session=discovery_session)
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace(
|
||||
current_session=Mock(return_value=None),
|
||||
identity_discovery_uow=Mock(return_value=DiscoveryContext()),
|
||||
execute_async=AsyncMock(side_effect=AssertionError('unscoped persistence access')),
|
||||
)
|
||||
ap.workspace_service = SimpleNamespace(instance_uuid='instance-a')
|
||||
service = UserService(ap)
|
||||
|
||||
result = await service.get_login_capabilities()
|
||||
|
||||
assert result == {
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
ap.persistence_mgr.identity_discovery_uow.assert_called_once()
|
||||
discovery_session.execute.assert_awaited_once()
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
|
||||
class TestUserServiceGetUserByEmail:
|
||||
"""Tests for get_user_by_email method."""
|
||||
|
||||
@@ -309,6 +452,50 @@ class TestUserServiceVerifyJwtToken:
|
||||
with pytest.raises(Exception): # jwt.DecodeError or similar
|
||||
await service.verify_jwt_token('invalid.token.here')
|
||||
|
||||
async def test_verify_jwt_token_rejects_foreign_audience(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
||||
service = UserService(ap)
|
||||
token = jwt.encode(
|
||||
{
|
||||
'user': 'verify@example.com',
|
||||
'iss': 'langbot-core',
|
||||
'aud': 'langbot-instance:another-instance',
|
||||
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1),
|
||||
},
|
||||
'test_secret',
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
with pytest.raises(jwt.InvalidAudienceError):
|
||||
await service.verify_jwt_token(token)
|
||||
|
||||
async def test_verify_jwt_token_accepts_legacy_community_token_only_in_oss(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
||||
ap.workspace_service = SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
policy=SimpleNamespace(multi_workspace_enabled=False),
|
||||
)
|
||||
service = UserService(ap)
|
||||
legacy_token = jwt.encode(
|
||||
{
|
||||
'user': 'legacy@example.com',
|
||||
'iss': 'LangBot-community',
|
||||
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1),
|
||||
},
|
||||
'test_secret',
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
assert await service.verify_jwt_token(legacy_token) == 'legacy@example.com'
|
||||
|
||||
ap.workspace_service.policy.multi_workspace_enabled = True
|
||||
with pytest.raises(jwt.MissingRequiredClaimError):
|
||||
await service.verify_jwt_token(legacy_token)
|
||||
|
||||
|
||||
class TestUserServiceResetPassword:
|
||||
"""Tests for reset_password method."""
|
||||
@@ -476,6 +663,71 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
ap.persistence_mgr.execute_async.assert_called()
|
||||
assert updated_user.space_account_uuid == 'existing-space-uuid'
|
||||
|
||||
async def test_cloud_login_updates_only_the_projected_space_account(self):
|
||||
projected = SimpleNamespace(
|
||||
uuid='projected-space-uuid',
|
||||
user='Cloud Owner',
|
||||
normalized_email='owner@example.com',
|
||||
password='',
|
||||
account_type='space',
|
||||
status=AccountStatus.ACTIVE.value,
|
||||
source=AccountSource.CLOUD_PROJECTION.value,
|
||||
projection_revision=7,
|
||||
space_account_uuid='projected-space-uuid',
|
||||
)
|
||||
persistence = SimpleNamespace(execute_async=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=persistence,
|
||||
workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
|
||||
)
|
||||
service = UserService(ap)
|
||||
service.get_user_by_space_account_uuid = AsyncMock(side_effect=[projected, projected])
|
||||
|
||||
result = await service.create_or_update_space_user(
|
||||
space_account_uuid='projected-space-uuid',
|
||||
email='OWNER@example.com',
|
||||
access_token='access-token',
|
||||
refresh_token='refresh-token',
|
||||
api_key='api-key',
|
||||
expires_in=3600,
|
||||
)
|
||||
|
||||
assert result is projected
|
||||
persistence.execute_async.assert_awaited_once()
|
||||
|
||||
async def test_cloud_login_never_creates_an_unprojected_account(self):
|
||||
persistence = SimpleNamespace(execute_async=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=persistence,
|
||||
workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
|
||||
)
|
||||
service = UserService(ap)
|
||||
service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(
|
||||
ControlPlaneDirectoryRequiredError,
|
||||
match='verified Cloud directory',
|
||||
):
|
||||
await service.create_or_update_space_user(
|
||||
space_account_uuid='unknown-space-uuid',
|
||||
email='unknown@example.com',
|
||||
access_token='access-token',
|
||||
refresh_token='refresh-token',
|
||||
api_key='api-key',
|
||||
expires_in=3600,
|
||||
)
|
||||
|
||||
persistence.execute_async.assert_not_awaited()
|
||||
|
||||
async def test_cloud_invitation_registration_requires_space_identity(self):
|
||||
ap = SimpleNamespace(
|
||||
workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
|
||||
)
|
||||
service = UserService(ap)
|
||||
|
||||
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
|
||||
await service.register_invited_account('invite-token', 'member@example.com', 'password')
|
||||
|
||||
async def test_create_or_update_new_space_user_first_init(self):
|
||||
"""Creates new Space user on first initialization."""
|
||||
# Setup
|
||||
@@ -522,8 +774,8 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
# Verify
|
||||
assert result.space_account_uuid == 'new-space-uuid'
|
||||
|
||||
async def test_create_or_update_space_user_already_initialized_raises_error(self):
|
||||
"""Raises AccountEmailMismatchError when system already initialized and user not found."""
|
||||
async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self):
|
||||
"""Unknown Space email is distinct from an existing local Account collision."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
@@ -538,7 +790,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
service.is_initialized = AsyncMock(return_value=True) # Already initialized
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
with pytest.raises(SpaceAccountNotRegisteredError):
|
||||
await service.create_or_update_space_user(
|
||||
space_account_uuid='unknown-space-uuid',
|
||||
email='unknown@example.com',
|
||||
@@ -548,6 +800,78 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
expires_in=3600,
|
||||
)
|
||||
|
||||
async def test_unknown_space_subject_cannot_claim_existing_account_by_email(self):
|
||||
"""An OAuth login collision requires the explicit account-bound bind flow."""
|
||||
existing_user = _create_mock_user(
|
||||
email='owner@example.com',
|
||||
account_type='local',
|
||||
space_account_uuid=None,
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
|
||||
provider_service=SimpleNamespace(update_space_model_provider_api_keys=AsyncMock()),
|
||||
space_service=SimpleNamespace(
|
||||
get_user_info_raw=AsyncMock(
|
||||
return_value={
|
||||
'account': {
|
||||
'uuid': 'attacker-space-subject',
|
||||
'email': 'owner@example.com',
|
||||
},
|
||||
'api_key': 'attacker-api-key',
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
service = UserService(ap)
|
||||
service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
|
||||
service.get_user_by_email = AsyncMock(return_value=existing_user)
|
||||
service.generate_jwt_token = AsyncMock(return_value='must-not-be-issued')
|
||||
|
||||
with pytest.raises(SpaceAccountBindingRequiredError):
|
||||
await service.authenticate_space_user(
|
||||
'attacker-access-token',
|
||||
'attacker-refresh-token',
|
||||
3600,
|
||||
)
|
||||
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
ap.provider_service.update_space_model_provider_api_keys.assert_not_awaited()
|
||||
service.generate_jwt_token.assert_not_awaited()
|
||||
|
||||
async def test_oss_space_provider_refresh_requires_workspace_owner(self):
|
||||
member_account = _create_mock_user(email='member@example.com', space_account_uuid='space-member')
|
||||
access = SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid='workspace-a'),
|
||||
membership=SimpleNamespace(role='admin'),
|
||||
)
|
||||
provider_service = SimpleNamespace(update_space_model_provider_api_keys=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=False)),
|
||||
workspace_collaboration_service=SimpleNamespace(list_account_workspaces=AsyncMock(return_value=[access])),
|
||||
provider_service=provider_service,
|
||||
)
|
||||
|
||||
await UserService(ap)._update_space_provider_for_account(member_account, 'member-api-key')
|
||||
|
||||
provider_service.update_space_model_provider_api_keys.assert_not_awaited()
|
||||
|
||||
async def test_oss_space_provider_refresh_uses_workspace_owner_credentials(self):
|
||||
owner_account = _create_mock_user(email='owner@example.com', space_account_uuid='space-owner')
|
||||
access = SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid='workspace-a'),
|
||||
membership=SimpleNamespace(role='owner'),
|
||||
)
|
||||
provider_service = SimpleNamespace(update_space_model_provider_api_keys=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=False)),
|
||||
workspace_collaboration_service=SimpleNamespace(list_account_workspaces=AsyncMock(return_value=[access])),
|
||||
provider_service=provider_service,
|
||||
)
|
||||
|
||||
await UserService(ap)._update_space_provider_for_account(owner_account, 'owner-api-key')
|
||||
|
||||
provider_service.update_space_model_provider_api_keys.assert_awaited_once_with('workspace-a', 'owner-api-key')
|
||||
|
||||
async def test_create_or_update_space_user_no_expiry(self):
|
||||
"""Creates Space user without token expiry."""
|
||||
# Setup
|
||||
@@ -594,6 +918,58 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
assert result is not None
|
||||
assert result.space_account_uuid == 'noexpiry-uuid'
|
||||
|
||||
async def test_bind_space_account_rejects_different_email(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
service.get_user_by_email = AsyncMock(return_value=_create_mock_user(email='invited@example.com'))
|
||||
service.ap.space_service = SimpleNamespace(
|
||||
exchange_oauth_code=AsyncMock(
|
||||
return_value={'access_token': 'access', 'refresh_token': 'refresh', 'expires_in': 3600}
|
||||
),
|
||||
get_user_info_raw=AsyncMock(
|
||||
return_value={
|
||||
'account': {'uuid': 'space-other', 'email': 'other@example.com'},
|
||||
'api_key': 'key',
|
||||
}
|
||||
),
|
||||
)
|
||||
service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
|
||||
service._identity_execute = AsyncMock()
|
||||
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
await service.bind_space_account('invited@example.com', 'code')
|
||||
|
||||
service._identity_execute.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace_owner_returns_user_object_from_core_connection_result(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
owner = _create_mock_user('owner@example.com', password='pw')
|
||||
service.ap.persistence_mgr = SimpleNamespace(
|
||||
current_session=lambda: SimpleNamespace(scalar=AsyncMock(return_value=owner)),
|
||||
)
|
||||
|
||||
resolved = await service.get_workspace_owner('workspace-1')
|
||||
|
||||
assert resolved is owner
|
||||
|
||||
|
||||
class TestUserServiceLoginCapabilities:
|
||||
async def test_capabilities_are_derived_from_all_accounts(self):
|
||||
result = SimpleNamespace(one=lambda: (2, 1))
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=result)))
|
||||
|
||||
capabilities = await UserService(ap).get_login_capabilities()
|
||||
|
||||
assert capabilities == {'password_login_enabled': True, 'space_login_enabled': True}
|
||||
|
||||
async def test_capabilities_disable_absent_login_methods(self):
|
||||
result = SimpleNamespace(one=lambda: (0, 0))
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=result)))
|
||||
|
||||
capabilities = await UserService(ap).get_login_capabilities()
|
||||
|
||||
assert capabilities == {'password_login_enabled': False, 'space_login_enabled': False}
|
||||
|
||||
|
||||
class TestUserServiceCreateUserLock:
|
||||
"""Tests for create_user_lock attribute."""
|
||||
|
||||
Reference in New Issue
Block a user