mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
fix(cloud): track workspace membership provenance
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_membership_source_migration_backfills_existing_rows_as_local_and_enforces_constraint(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "membership-source.db"}')
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE TABLE workspace_memberships (
|
||||
uuid VARCHAR(36) PRIMARY KEY,
|
||||
workspace_uuid VARCHAR(36) NOT NULL,
|
||||
account_uuid VARCHAR(36) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
projection_revision BIGINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO workspace_memberships
|
||||
(uuid, workspace_uuid, account_uuid, role, status, projection_revision)
|
||||
VALUES
|
||||
('00000000-0000-4000-8000-000000000001', 'workspace', 'local-account',
|
||||
'viewer', 'active', 0),
|
||||
('00000000-0000-4000-8000-000000000002', 'workspace', 'cloud-account',
|
||||
'viewer', 'active', 0)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
await run_alembic_stamp(engine, '0019_single_workspace_owner')
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
async with engine.connect() as connection:
|
||||
rows = (
|
||||
await connection.execute(sa.text('SELECT uuid, source FROM workspace_memberships ORDER BY uuid'))
|
||||
).all()
|
||||
columns = await connection.run_sync(
|
||||
lambda sync_connection: {
|
||||
column['name']: column
|
||||
for column in sa.inspect(sync_connection).get_columns('workspace_memberships')
|
||||
}
|
||||
)
|
||||
assert rows == [
|
||||
('00000000-0000-4000-8000-000000000001', 'local'),
|
||||
('00000000-0000-4000-8000-000000000002', 'local'),
|
||||
]
|
||||
assert columns['source']['nullable'] is False
|
||||
|
||||
with pytest.raises(sa.exc.IntegrityError):
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sa.text("UPDATE workspace_memberships SET source = 'guessed-from-user-source'")
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -1056,7 +1056,7 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context):
|
||||
await service.initialize()
|
||||
|
||||
|
||||
async def test_revision_zero_membership_in_authoritative_directory_is_adopted(projection_context):
|
||||
async def test_directory_revision_zero_membership_is_adopted(projection_context):
|
||||
application, session_factory = projection_context
|
||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
@@ -1074,12 +1074,13 @@ async def test_revision_zero_membership_in_authoritative_directory_is_adopted(pr
|
||||
|
||||
async with session_factory() as session:
|
||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
||||
assert membership.source == 'cloud_projection'
|
||||
assert membership.role == 'owner'
|
||||
assert membership.status == 'removed'
|
||||
assert membership.projection_revision == 2
|
||||
|
||||
|
||||
async def test_revision_zero_membership_absent_from_authoritative_directory_is_removed(projection_context):
|
||||
async def test_directory_revision_zero_membership_omitted_from_snapshot_is_removed(projection_context):
|
||||
application, session_factory = projection_context
|
||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
@@ -1108,6 +1109,7 @@ async def test_revision_zero_membership_absent_from_authoritative_directory_is_r
|
||||
account_uuid=historical_account_uuid,
|
||||
role='viewer',
|
||||
status='active',
|
||||
source='cloud_projection',
|
||||
joined_at=membership.joined_at,
|
||||
projection_revision=0,
|
||||
)
|
||||
@@ -1121,35 +1123,37 @@ async def test_revision_zero_membership_absent_from_authoritative_directory_is_r
|
||||
assert historical.projection_revision == 2
|
||||
|
||||
|
||||
async def test_revision_zero_local_collaboration_survives_directory_omission(projection_context):
|
||||
async def test_cloud_account_core_invitation_membership_survives_directory_omission(projection_context):
|
||||
application, session_factory = projection_context
|
||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
local_account_uuid = '20000000-0000-0000-0000-000000000098'
|
||||
invited_account_uuid = '20000000-0000-0000-0000-000000000098'
|
||||
async with session_factory() as session:
|
||||
async with session.begin():
|
||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
||||
projected_membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
||||
session.add(
|
||||
User(
|
||||
uuid=local_account_uuid,
|
||||
user='Local Collaborator',
|
||||
normalized_email='local@example.com',
|
||||
uuid=invited_account_uuid,
|
||||
user='Invited Cloud Account',
|
||||
normalized_email='invited-cloud@example.com',
|
||||
password='',
|
||||
status='active',
|
||||
source='local',
|
||||
projection_revision=0,
|
||||
account_type='local',
|
||||
source='cloud_projection',
|
||||
projection_revision=1,
|
||||
account_type='space',
|
||||
space_account_uuid=invited_account_uuid,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
WorkspaceMembership(
|
||||
uuid=SECOND_MEMBERSHIP_UUID,
|
||||
workspace_uuid=WORKSPACE_UUID,
|
||||
account_uuid=local_account_uuid,
|
||||
account_uuid=invited_account_uuid,
|
||||
role='viewer',
|
||||
status='active',
|
||||
joined_at=membership.joined_at,
|
||||
source='local',
|
||||
joined_at=projected_membership.joined_at,
|
||||
projection_revision=0,
|
||||
)
|
||||
)
|
||||
@@ -1157,6 +1161,31 @@ async def test_revision_zero_local_collaboration_survives_directory_omission(pro
|
||||
await service.apply_snapshot(_snapshot(2))
|
||||
|
||||
async with session_factory() as session:
|
||||
local = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
||||
assert local.status == 'active'
|
||||
assert local.projection_revision == 0
|
||||
membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
||||
assert membership.source == 'local'
|
||||
assert membership.status == 'active'
|
||||
assert membership.projection_revision == 0
|
||||
|
||||
|
||||
async def test_directory_does_not_adopt_local_membership_with_different_uuid_for_same_cloud_account(projection_context):
|
||||
application, session_factory = projection_context
|
||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
async with session_factory() as session:
|
||||
async with session.begin():
|
||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
||||
membership.uuid = SECOND_MEMBERSHIP_UUID
|
||||
membership.source = 'local'
|
||||
membership.projection_revision = 0
|
||||
|
||||
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
||||
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
||||
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
||||
|
||||
async with session_factory() as session:
|
||||
membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
||||
assert membership.source == 'local'
|
||||
assert membership.role == 'developer'
|
||||
assert membership.status == 'active'
|
||||
assert membership.projection_revision == 0
|
||||
|
||||
@@ -99,6 +99,7 @@ async def test_invitation_secret_is_hashed_and_acceptance_is_one_time(collaborat
|
||||
membership = await service.accept_invitation(created.token, account.uuid)
|
||||
assert membership.workspace_uuid == workspace.uuid
|
||||
assert membership.role == 'developer'
|
||||
assert membership.source == 'local'
|
||||
|
||||
with pytest.raises(InvitationUsedError):
|
||||
await service.accept_invitation(created.token, account.uuid)
|
||||
|
||||
@@ -153,6 +153,33 @@ async def test_initial_owner_cannot_be_claimed_by_another_account(workspace_test
|
||||
).all()
|
||||
assert len(owners) == 1
|
||||
assert owners[0].account_uuid == first_account_uuid
|
||||
assert owners[0].source == 'local'
|
||||
|
||||
|
||||
async def test_claim_initial_owner_reclassifies_existing_membership_as_local(workspace_test_context):
|
||||
service, session_factory = workspace_test_context
|
||||
|
||||
async with session_factory() as session:
|
||||
async with session.begin():
|
||||
account_uuid = await _insert_account(session, 'reclaimed@example.com')
|
||||
workspace = await service.ensure_singleton_workspace(session=session)
|
||||
session.add(
|
||||
WorkspaceMembership(
|
||||
uuid='44444444-4444-4444-8444-444444444444',
|
||||
workspace_uuid=workspace.uuid,
|
||||
account_uuid=account_uuid,
|
||||
role='viewer',
|
||||
status='removed',
|
||||
source='cloud_projection',
|
||||
projection_revision=4,
|
||||
)
|
||||
)
|
||||
|
||||
membership = await service.claim_initial_owner(account_uuid)
|
||||
|
||||
assert membership.role == 'owner'
|
||||
assert membership.status == 'active'
|
||||
assert membership.source == 'local'
|
||||
|
||||
|
||||
async def test_execution_binding_returns_persisted_generation(workspace_test_context):
|
||||
|
||||
Reference in New Issue
Block a user