mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +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):
|
||||
|
||||
Reference in New Issue
Block a user