mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(oss): enforce invitation account and owner billing flows
This commit is contained in:
@@ -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': {}},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -291,6 +291,9 @@ class TestUserInitEndpoint:
|
||||
@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')
|
||||
)
|
||||
@@ -302,10 +305,32 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'password_login_enabled': True,
|
||||
'space_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:
|
||||
|
||||
@@ -260,6 +260,28 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -231,8 +231,15 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
||||
},
|
||||
)
|
||||
assert accept_response.status_code == 200
|
||||
member_auth = (await accept_response.get_json())['data']
|
||||
assert member_auth['workspace_uuid'] == workspace_uuid
|
||||
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',
|
||||
@@ -249,7 +256,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
||||
|
||||
member_current_response = await client.get(
|
||||
'/api/v1/workspaces/current',
|
||||
headers=_auth(member_auth['token'], workspace_uuid),
|
||||
headers=_auth(member_token, workspace_uuid),
|
||||
)
|
||||
assert member_current_response.status_code == 200
|
||||
member_current = (await member_current_response.get_json())['data']
|
||||
@@ -258,15 +265,29 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
||||
|
||||
forbidden_invite = await client.post(
|
||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||
headers=_auth(member_auth['token'], workspace_uuid),
|
||||
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_invitation_accept_rejects_invalid_bearer_as_authentication_failure(workspace_api):
|
||||
_, client, _, _ = workspace_api
|
||||
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',
|
||||
@@ -368,7 +389,14 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac
|
||||
'registration': {'email': 'viewer@example.com', 'password': 'viewer-password'},
|
||||
},
|
||||
)
|
||||
viewer_token = (await accept_response.get_json())['data']['token']
|
||||
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),
|
||||
@@ -382,6 +410,7 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in
|
||||
workspace_api,
|
||||
):
|
||||
application, client, engine, owner_token = workspace_api
|
||||
application.deployment = SimpleNamespace(mode='cloud')
|
||||
owner_uuid = jwt.decode(
|
||||
owner_token,
|
||||
'workspace-api-secret',
|
||||
@@ -537,8 +566,8 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in
|
||||
'registration': {'email': 'member@example.com', 'password': 'member-password'},
|
||||
},
|
||||
)
|
||||
assert registration_response.status_code == 409
|
||||
assert (await registration_response.get_json())['code'] == 'control_plane_required'
|
||||
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):
|
||||
|
||||
@@ -24,7 +24,11 @@ from langbot.pkg.api.http.service.user import (
|
||||
UserService,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User
|
||||
from langbot.pkg.entity.errors.account import AccountEmailMismatchError
|
||||
from langbot.pkg.entity.errors.account import (
|
||||
AccountEmailMismatchError,
|
||||
SpaceAccountBindingRequiredError,
|
||||
SpaceAccountNotRegisteredError,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -97,6 +101,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
|
||||
@@ -694,8 +699,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()
|
||||
@@ -710,7 +715,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',
|
||||
@@ -747,7 +752,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
service.get_user_by_email = AsyncMock(return_value=existing_user)
|
||||
service.generate_jwt_token = AsyncMock(return_value='must-not-be-issued')
|
||||
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
with pytest.raises(SpaceAccountBindingRequiredError):
|
||||
await service.authenticate_space_user(
|
||||
'attacker-access-token',
|
||||
'attacker-refresh-token',
|
||||
@@ -758,6 +763,46 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
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
|
||||
@@ -805,6 +850,49 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
||||
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()
|
||||
|
||||
|
||||
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