mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
feat(workspace): add in-product collaboration and direct Cloud launch
This commit is contained in:
@@ -46,10 +46,17 @@ async def space_oauth_api():
|
||||
local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None
|
||||
)
|
||||
)
|
||||
application.user_service.consume_space_oauth_state_details = AsyncMock(
|
||||
return_value=SimpleNamespace(launch_workspace_uuid=None)
|
||||
)
|
||||
application.user_service.bind_space_account = AsyncMock(return_value=bound_account)
|
||||
application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token')
|
||||
application.user_service.get_user_by_uuid = AsyncMock(return_value=bound_account)
|
||||
application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account))
|
||||
application.user_service.verify_jwt_token = AsyncMock()
|
||||
application.space_launch_service.consume_assertion = AsyncMock(
|
||||
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
||||
)
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||
application.space_service.get_oauth_authorize_url = Mock(
|
||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||
@@ -87,6 +94,29 @@ async def test_public_login_state_is_server_issued(space_oauth_api):
|
||||
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.multi_workspace_enabled = True
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={
|
||||
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||
'launch_workspace_uuid': WORKSPACE_UUID,
|
||||
},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||
assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
|
||||
application.user_service.issue_space_oauth_state.assert_awaited_once_with(
|
||||
'login',
|
||||
launch_workspace_uuid=WORKSPACE_UUID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -203,10 +233,33 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
||||
assert (await missing.get_json())['code'] == 1
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||
application.user_service.consume_space_oauth_state.assert_awaited_once_with('opaque-login-state', 'login')
|
||||
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')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.user_service.consume_space_oauth_state_details.reset_mock()
|
||||
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': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = (await response.get_json())['data']
|
||||
assert data['token'] == 'space-login-token'
|
||||
assert data['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
|
||||
'account-a',
|
||||
WORKSPACE_UUID,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
@@ -234,3 +287,30 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
||||
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('owner@example.com', 'oauth-code')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.user_service.consume_space_oauth_state.reset_mock()
|
||||
application.space_service.exchange_oauth_code.reset_mock()
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'state': 'space-generated-state-is-not-oauth-state',
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = (await response.get_json())['data']
|
||||
assert data['token'] == 'rotated-account-token'
|
||||
assert data['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.space_launch_service.consume_assertion.assert_awaited_once_with(
|
||||
'signed-launch-token',
|
||||
expected_workspace_uuid=WORKSPACE_UUID,
|
||||
)
|
||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
@@ -53,7 +53,7 @@ async def workspace_api(tmp_path):
|
||||
'jwt': {'secret': 'workspace-api-secret', 'expire': 3600},
|
||||
'allow_modify_login_info': True,
|
||||
},
|
||||
'api': {'global_api_key': ''},
|
||||
'api': {'global_api_key': '', 'webui_url': 'https://langbot.example'},
|
||||
}
|
||||
)
|
||||
application.logger = logging.getLogger('workspace-api-test')
|
||||
@@ -198,6 +198,8 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
||||
invite_data = (await invite_response.get_json())['data']
|
||||
invitation_token = invite_data['token']
|
||||
assert invitation_token.startswith('lbi_')
|
||||
assert invite_data['link'] == f'https://langbot.example/invitations/accept#token={invitation_token}'
|
||||
assert invite_data['delivery'] == {'status': 'link_only', 'provider': None}
|
||||
assert 'token_hash' not in invite_data['invitation']
|
||||
|
||||
async with engine.connect() as connection:
|
||||
@@ -376,7 +378,7 @@ 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_cloud_projection_is_selected_explicitly_and_directory_writes_use_control_plane(
|
||||
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
||||
workspace_api,
|
||||
):
|
||||
application, client, engine, owner_token = workspace_api
|
||||
@@ -514,8 +516,42 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_
|
||||
headers=_auth(owner_token, cloud_workspace_uuid),
|
||||
json={'email': 'member@example.com', 'role': 'viewer'},
|
||||
)
|
||||
assert create_invitation.status_code == 409
|
||||
assert (await create_invitation.get_json())['code'] == 'control_plane_required'
|
||||
assert create_invitation.status_code == 200
|
||||
created_invitation = (await create_invitation.get_json())['data']
|
||||
assert created_invitation['invitation']['workspace_uuid'] == cloud_workspace_uuid
|
||||
assert created_invitation['link'].startswith('https://langbot.example/invitations/accept#token=lbi_')
|
||||
assert created_invitation['delivery'] == {'status': 'link_only', 'provider': None}
|
||||
|
||||
accept_response = await client.post(
|
||||
'/api/v1/invitations/accept',
|
||||
json={
|
||||
'token': created_invitation['token'],
|
||||
'registration': {'email': 'member@example.com', 'password': 'member-password'},
|
||||
},
|
||||
)
|
||||
assert accept_response.status_code == 200
|
||||
member_token = (await accept_response.get_json())['data']['token']
|
||||
|
||||
member_current = await client.get(
|
||||
'/api/v1/workspaces/current',
|
||||
headers=_auth(member_token, cloud_workspace_uuid),
|
||||
)
|
||||
assert member_current.status_code == 200
|
||||
member_account_uuid = (await member_current.get_json())['data']['membership']['account_uuid']
|
||||
|
||||
update_member = await client.patch(
|
||||
f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}',
|
||||
headers=_auth(owner_token, cloud_workspace_uuid),
|
||||
json={'role': 'developer'},
|
||||
)
|
||||
assert update_member.status_code == 200
|
||||
assert (await update_member.get_json())['data']['member']['role'] == 'developer'
|
||||
|
||||
remove_member = await client.delete(
|
||||
f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}',
|
||||
headers=_auth(owner_token, cloud_workspace_uuid),
|
||||
)
|
||||
assert remove_member.status_code == 200
|
||||
|
||||
|
||||
async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
|
||||
|
||||
@@ -973,7 +973,7 @@ class TestPostgreSQLTenantRuntime:
|
||||
.values(write_fenced=True)
|
||||
)
|
||||
assert workspace_update.rowcount == 0
|
||||
assert membership_update.rowcount == 0
|
||||
assert membership_update.rowcount == 1
|
||||
assert execution_update.rowcount == 0
|
||||
|
||||
async with cloud_manager.tenant_uow(workspace_local) as tenant:
|
||||
|
||||
@@ -64,12 +64,29 @@ class TestSpaceOAuthState:
|
||||
service = UserService(SimpleNamespace())
|
||||
state = await service.issue_space_oauth_state('login')
|
||||
digest = service._space_oauth_state_digest(state)
|
||||
purpose, account_uuid, _ = service._space_oauth_states[digest]
|
||||
service._space_oauth_states[digest] = (purpose, account_uuid, 0)
|
||||
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'
|
||||
|
||||
|
||||
def _create_mock_user(
|
||||
email: str = 'test@example.com',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
INSTANCE_UUID = 'instance-test'
|
||||
ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
WORKSPACE_UUID = '22222222-2222-4222-8222-222222222222'
|
||||
KEY_ID = 'space-key-1'
|
||||
|
||||
|
||||
def _base64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str:
|
||||
header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'}
|
||||
encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))
|
||||
encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8'))
|
||||
signing_input = f'{encoded_header}.{encoded_claims}'
|
||||
return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}'
|
||||
|
||||
|
||||
def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict:
|
||||
return {
|
||||
'iss': 'langbot-space',
|
||||
'aud': 'langbot-cloud-runtime',
|
||||
'sub': f'langbot-instance:{INSTANCE_UUID}',
|
||||
'jti': jti or str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'nbf': now - 5,
|
||||
'exp': now + 90,
|
||||
'instance_uuid': INSTANCE_UUID,
|
||||
'kind': 'workspace.launch',
|
||||
'payload': {
|
||||
'account_uuid': ACCOUNT_UUID,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
|
||||
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'space': {
|
||||
'launch': {
|
||||
'control_plane_public_key': _base64url(public_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
return SpaceLaunchService(app, wall_time=lambda: now)
|
||||
|
||||
|
||||
async def test_consumes_valid_workspace_launch_assertion_once():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
now = int(time.time())
|
||||
service = _service(private_key, now=now)
|
||||
token = _sign(private_key, _claims(now=now))
|
||||
|
||||
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID}
|
||||
with pytest.raises(SpaceLaunchError, match='already been consumed'):
|
||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
|
||||
async def test_rejects_expired_wrong_workspace_and_wrong_instance_assertions():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
now = int(time.time())
|
||||
service = _service(private_key, now=now)
|
||||
|
||||
expired = _claims(now=now)
|
||||
expired['exp'] = now - 60
|
||||
with pytest.raises(SpaceLaunchError, match='expired'):
|
||||
await service.consume_assertion(_sign(private_key, expired), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
wrong_workspace = _sign(private_key, _claims(now=now, workspace_uuid='33333333-3333-4333-8333-333333333333'))
|
||||
with pytest.raises(SpaceLaunchError, match='another Workspace'):
|
||||
await service.consume_assertion(wrong_workspace, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
wrong_instance = _claims(now=now)
|
||||
wrong_instance['instance_uuid'] = 'other-instance'
|
||||
with pytest.raises(SpaceLaunchError, match='instance UUID'):
|
||||
await service.consume_assertion(_sign(private_key, wrong_instance), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
|
||||
async def test_rejects_invalid_signature_and_non_cloud_mode():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
now = int(time.time())
|
||||
token = _sign(private_key, _claims(now=now))
|
||||
service = _service(Ed25519PrivateKey.generate(), now=now)
|
||||
with pytest.raises(SpaceLaunchError, match='signature'):
|
||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
oss_service = _service(private_key, now=now)
|
||||
oss_service.ap.deployment.multi_workspace_enabled = False
|
||||
with pytest.raises(SpaceLaunchError, match='verified Cloud mode'):
|
||||
await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.workspace.invitation_delivery import (
|
||||
InvitationDeliveryResult,
|
||||
InvitationDeliveryService,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _app(config: dict) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data=config),
|
||||
logger=SimpleNamespace(warning=lambda *args, **kwargs: None),
|
||||
)
|
||||
|
||||
|
||||
async def test_link_only_delivery_builds_public_web_link_without_secret_capability():
|
||||
service = InvitationDeliveryService(
|
||||
_app(
|
||||
{
|
||||
'api': {
|
||||
'webui_url': 'https://public.langbot.example/',
|
||||
'webhook_prefix': 'http://internal:5300',
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert service.build_invitation_link('lbi_secret') == (
|
||||
'https://public.langbot.example/invitations/accept#token=lbi_secret'
|
||||
)
|
||||
assert service.capability() == {'enabled': False, 'provider': None}
|
||||
result = await service.deliver_invitation(
|
||||
recipient_email='member@example.com',
|
||||
workspace_name='Workspace',
|
||||
invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
|
||||
)
|
||||
assert result == InvitationDeliveryResult(status='link_only', provider=None)
|
||||
|
||||
|
||||
async def test_configured_provider_failure_returns_failed_without_raising():
|
||||
service = InvitationDeliveryService(
|
||||
_app(
|
||||
{
|
||||
'workspace': {
|
||||
'invitations': {
|
||||
'email': {
|
||||
'provider': 'resend',
|
||||
'from': 'LangBot <noreply@example.com>',
|
||||
'resend': {'api_key': 'resend-secret'},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
service._send_resend = AsyncMock(return_value=False)
|
||||
|
||||
assert service.capability() == {'enabled': True, 'provider': 'resend'}
|
||||
result = await service.deliver_invitation(
|
||||
recipient_email='member@example.com',
|
||||
workspace_name='Workspace',
|
||||
invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
|
||||
)
|
||||
|
||||
assert result == InvitationDeliveryResult(status='failed', provider='resend')
|
||||
service._send_resend.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_environment_mapping_enables_provider_without_leaking_secret(monkeypatch):
|
||||
monkeypatch.setenv('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', 'https://env.langbot.example/')
|
||||
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', 'smtp')
|
||||
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__FROM', 'LangBot <noreply@example.com>')
|
||||
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', 'smtp.example.com')
|
||||
monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', 'smtp-secret')
|
||||
service = InvitationDeliveryService(_app({'api': {'webui_url': 'https://config.example'}}))
|
||||
|
||||
assert service.build_invitation_link('lbi_secret') == (
|
||||
'https://env.langbot.example/invitations/accept#token=lbi_secret'
|
||||
)
|
||||
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
|
||||
@@ -240,7 +240,7 @@ async def test_cloud_member_listing_opens_workspace_uow_when_request_scope_has_c
|
||||
]
|
||||
|
||||
|
||||
async def test_cloud_directory_requires_explicit_selector_and_rejects_local_mutation(
|
||||
async def test_cloud_directory_requires_explicit_selector_and_allows_core_membership_management(
|
||||
collaboration_context,
|
||||
):
|
||||
_service, workspace_service, session_factory, owner, _workspace, _membership = collaboration_context
|
||||
@@ -292,13 +292,34 @@ async def test_cloud_directory_requires_explicit_selector_and_rejects_local_muta
|
||||
assert access.workspace.uuid == cloud_workspace_uuid
|
||||
assert access.execution.placement_generation == 8
|
||||
|
||||
with pytest.raises(MembershipPermissionError, match='control plane'):
|
||||
await cloud_service.create_invitation(
|
||||
cloud_workspace_uuid,
|
||||
cloud_membership,
|
||||
'member@example.com',
|
||||
'viewer',
|
||||
)
|
||||
created = await cloud_service.create_invitation(
|
||||
cloud_workspace_uuid,
|
||||
cloud_membership,
|
||||
'member@example.com',
|
||||
'viewer',
|
||||
)
|
||||
assert created.invitation.workspace_uuid == cloud_workspace_uuid
|
||||
assert created.token.startswith('lbi_')
|
||||
|
||||
member = await _add_account(session_factory, 'member@example.com')
|
||||
membership = await cloud_service.accept_invitation(created.token, member.uuid)
|
||||
assert membership.workspace_uuid == cloud_workspace_uuid
|
||||
assert membership.role == 'viewer'
|
||||
|
||||
updated = await cloud_service.update_member_role(
|
||||
cloud_workspace_uuid,
|
||||
member.uuid,
|
||||
'developer',
|
||||
cloud_membership,
|
||||
)
|
||||
assert updated.role == 'developer'
|
||||
|
||||
removed = await cloud_service.remove_member(
|
||||
cloud_workspace_uuid,
|
||||
member.uuid,
|
||||
cloud_membership,
|
||||
)
|
||||
assert removed.status == 'removed'
|
||||
|
||||
|
||||
async def test_invitation_lock_registry_does_not_retain_sequential_tokens(collaboration_context):
|
||||
|
||||
Reference in New Issue
Block a user