mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 04:16:07 +00:00
feat(tenancy): implement workspace isolation
This commit is contained in:
@@ -6,10 +6,51 @@ Tests query management, ID generation, and async context handling.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.pipeline.pool import QueryPool
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.pipeline.pool import (
|
||||
ExecutionContextMismatchError,
|
||||
ExecutionContextRequiredError,
|
||||
QueryNotFoundError,
|
||||
QueryPool,
|
||||
get_query_execution_context,
|
||||
)
|
||||
|
||||
|
||||
TEST_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
def oss_pool():
|
||||
"""Build the explicit singleton resolver used by the OSS compatibility path."""
|
||||
return QueryPool(singleton_context_resolver=lambda: TEST_CONTEXT)
|
||||
|
||||
|
||||
async def add_scoped_mock_query(pool, context, *, bot_uuid='bot-a'):
|
||||
"""Create a Query through the real pool while keeping SDK details mocked."""
|
||||
query = Mock()
|
||||
query.bot_uuid = bot_uuid
|
||||
query.pipeline_uuid = None
|
||||
query.query_id = pool.query_id_counter
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query', return_value=query):
|
||||
return await pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=Mock(),
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
execution_context=context,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -39,7 +80,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_adds_query_with_id(self):
|
||||
"""add_query creates, stores, and caches a Query with the correct ID."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
# Mock Query creation
|
||||
mock_query = Mock()
|
||||
@@ -62,12 +103,12 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
# Query is added to list and cache
|
||||
assert pool.queries[0] is mock_query
|
||||
assert pool.cached_queries[0] is mock_query
|
||||
assert pool.cached_queries[('workspace-test', mock_query.query_uuid)] is mock_query
|
||||
assert mock_query.query_id == 0
|
||||
|
||||
async def test_add_query_increments_counter(self):
|
||||
"""Each add_query increments the counter."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query1 = Mock()
|
||||
mock_query1.query_id = 0
|
||||
@@ -103,7 +144,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_appends_to_list(self):
|
||||
"""Query is appended to queries list."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -126,7 +167,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_caches_query(self):
|
||||
"""Query is cached by query_id."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -144,12 +185,13 @@ class TestQueryPoolAddQuery:
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
assert 0 in pool.cached_queries
|
||||
assert pool.cached_queries[0] is mock_query
|
||||
cache_key = ('workspace-test', mock_query.query_uuid)
|
||||
assert cache_key in pool.cached_queries
|
||||
assert pool.cached_queries[cache_key] is mock_query
|
||||
|
||||
async def test_add_query_with_pipeline_uuid(self):
|
||||
"""Query can have pipeline_uuid set."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -175,7 +217,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_sets_routed_by_rule_variable(self):
|
||||
"""Query has _routed_by_rule variable."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -201,7 +243,7 @@ class TestQueryPoolAddQuery:
|
||||
|
||||
async def test_add_query_notifier_condition(self):
|
||||
"""add_query notifies waiting consumers."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_query = Mock()
|
||||
mock_query.query_id = 0
|
||||
@@ -237,7 +279,7 @@ class TestQueryPoolContext:
|
||||
|
||||
async def test_aenter_acquires_lock(self):
|
||||
"""__aenter__ acquires the pool lock."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
async with pool as p:
|
||||
# Lock is acquired
|
||||
@@ -260,7 +302,7 @@ class TestQueryPoolEdgeCases:
|
||||
|
||||
async def test_multiple_queries_cached_correctly(self):
|
||||
"""Multiple queries are cached separately."""
|
||||
pool = QueryPool()
|
||||
pool = oss_pool()
|
||||
|
||||
mock_queries = []
|
||||
for i in range(5):
|
||||
@@ -287,4 +329,107 @@ class TestQueryPoolEdgeCases:
|
||||
|
||||
# Each query is cached by its ID
|
||||
for i in range(5):
|
||||
assert pool.cached_queries[i] is mock_queries[i]
|
||||
query = mock_queries[i]
|
||||
assert pool.cached_queries[('workspace-test', query.query_uuid)] is query
|
||||
|
||||
|
||||
class TestQueryPoolWorkspaceIsolation:
|
||||
"""Regression coverage for trusted scope and scoped cache indexes."""
|
||||
|
||||
async def test_add_query_requires_execution_context_by_default(self):
|
||||
with pytest.raises(ExecutionContextRequiredError):
|
||||
await QueryPool().add_query(
|
||||
bot_uuid='bot-a',
|
||||
launcher_type=Mock(),
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
)
|
||||
|
||||
async def test_serialized_scope_fields_are_not_trusted_context(self):
|
||||
forged_query = SimpleNamespace(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
bot_uuid='bot-a',
|
||||
pipeline_uuid=None,
|
||||
query_uuid='forged-query',
|
||||
)
|
||||
|
||||
with pytest.raises(ExecutionContextRequiredError):
|
||||
get_query_execution_context(forged_query)
|
||||
|
||||
async def test_query_lookup_is_workspace_scoped(self):
|
||||
pool = QueryPool()
|
||||
query = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
uuid.UUID(query.query_uuid)
|
||||
assert await pool.get_query('workspace-test', query.query_uuid) is query
|
||||
assert await pool.get_query('workspace-other', query.query_uuid) is None
|
||||
assert await pool.get_query_by_legacy_id('workspace-test', 0) is query
|
||||
assert await pool.get_query_by_legacy_id('workspace-other', 0) is None
|
||||
with pytest.raises(QueryNotFoundError):
|
||||
await pool.require_query('workspace-other', query.query_uuid)
|
||||
|
||||
async def test_cache_separates_same_opaque_id_between_workspaces(self, monkeypatch):
|
||||
fixed_uuid = uuid.UUID('11111111-1111-4111-8111-111111111111')
|
||||
monkeypatch.setattr('langbot.pkg.pipeline.pool.uuid.uuid4', lambda: fixed_uuid)
|
||||
pool = QueryPool()
|
||||
context_a = TEST_CONTEXT
|
||||
context_b = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-other',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
query_a = await add_scoped_mock_query(pool, context_a)
|
||||
query_b = await add_scoped_mock_query(pool, context_b)
|
||||
|
||||
assert query_a.query_uuid == query_b.query_uuid
|
||||
assert await pool.get_query('workspace-test', query_a.query_uuid) is query_a
|
||||
assert await pool.get_query('workspace-other', query_b.query_uuid) is query_b
|
||||
|
||||
async def test_remove_query_cleans_both_scoped_indexes(self):
|
||||
pool = QueryPool()
|
||||
query = await add_scoped_mock_query(pool, TEST_CONTEXT)
|
||||
|
||||
assert await pool.remove_query(query) is True
|
||||
assert await pool.get_query('workspace-test', query.query_uuid) is None
|
||||
assert await pool.get_query_by_legacy_id('workspace-test', query.query_id) is None
|
||||
assert await pool.remove_query(query) is False
|
||||
|
||||
async def test_context_cannot_substitute_bot_identity(self):
|
||||
context = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
bot_uuid='bot-b',
|
||||
)
|
||||
|
||||
with pytest.raises(ExecutionContextMismatchError):
|
||||
await add_scoped_mock_query(QueryPool(), context, bot_uuid='bot-a')
|
||||
|
||||
async def test_query_counter_is_scoped_by_workspace_and_generation(self):
|
||||
pool = QueryPool()
|
||||
workspace_a = TEST_CONTEXT
|
||||
workspace_b = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-other',
|
||||
placement_generation=1,
|
||||
)
|
||||
next_generation = ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=2,
|
||||
)
|
||||
|
||||
await add_scoped_mock_query(pool, workspace_a)
|
||||
await add_scoped_mock_query(pool, workspace_a)
|
||||
await add_scoped_mock_query(pool, workspace_b)
|
||||
|
||||
assert pool.get_query_count(workspace_a) == 2
|
||||
assert pool.get_query_count(workspace_b) == 1
|
||||
assert pool.get_query_count(next_generation) == 0
|
||||
assert pool.query_id_counter == 3
|
||||
|
||||
Reference in New Issue
Block a user