mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 11:56:09 +00:00
feat(tenancy): harden shared cloud runtime boundaries
This commit is contained in:
@@ -82,6 +82,9 @@ class FakeApp:
|
||||
def _create_mock_persistence_manager(self):
|
||||
persistence_mgr = AsyncMock()
|
||||
persistence_mgr.execute_async = AsyncMock()
|
||||
# AsyncMock invents arbitrary callable attributes on access. Keep the
|
||||
# optional production UoW hook explicitly absent unless a test opts in.
|
||||
persistence_mgr.tenant_uow = None
|
||||
return persistence_mgr
|
||||
|
||||
def _create_mock_query_pool(self):
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.box import BoxRouterGroup
|
||||
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -34,6 +35,8 @@ async def box_security_api():
|
||||
'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'),
|
||||
}
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
|
||||
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
|
||||
@@ -41,6 +44,7 @@ async def box_security_api():
|
||||
application.box_service.get_status = AsyncMock(return_value={'enabled': True})
|
||||
application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
|
||||
application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
|
||||
application.box_service.managed_admission_required = False
|
||||
|
||||
quart_app = quart.Quart(__name__)
|
||||
router = BoxRouterGroup(application, quart_app)
|
||||
@@ -81,3 +85,17 @@ async def test_owner_can_audit_box_sessions_and_errors(box_security_api):
|
||||
assert errors.status_code == 200
|
||||
application.box_service.get_sessions.assert_awaited_once()
|
||||
application.box_service.get_recent_errors.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_status_returns_explicit_403_when_workspace_has_no_managed_sandbox(box_security_api):
|
||||
application, client = box_security_api
|
||||
application.box_service.get_status.side_effect = EntitlementUnavailableError(
|
||||
'Workspace entitlement does not grant managed_sandbox'
|
||||
)
|
||||
|
||||
response = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
|
||||
|
||||
assert response.status_code == 403
|
||||
payload = await response.get_json()
|
||||
assert payload['code'] == 'managed_sandbox_unavailable'
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from quart import Quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.workspaces import WorkspacesRouterGroup
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
def _authorization(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
if workspace_uuid is not None:
|
||||
headers['X-Workspace-Id'] = workspace_uuid
|
||||
return headers
|
||||
|
||||
|
||||
async def test_fresh_oss_workspace_http_journey_uses_real_sqlite_persistence(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Exercise the first-run Workspace journey through real HTTP handlers."""
|
||||
|
||||
instance_uuid = 'fresh-oss-workspace-journey'
|
||||
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
|
||||
|
||||
application = SimpleNamespace(
|
||||
logger=logging.getLogger('fresh-oss-workspace-journey-test'),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'sqlite',
|
||||
'sqlite': {'path': str(tmp_path / 'langbot.db')},
|
||||
},
|
||||
'system': {
|
||||
'jwt': {'secret': 'fresh-oss-workspace-secret', 'expire': 3600},
|
||||
'allow_modify_login_info': True,
|
||||
'limitation': {},
|
||||
'outbound_ips': [],
|
||||
},
|
||||
'api': {'global_api_key': ''},
|
||||
'plugin': {'enable_marketplace': True},
|
||||
'space': {
|
||||
'url': 'https://space.langbot.app',
|
||||
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
|
||||
'disable_models_service': False,
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
}
|
||||
),
|
||||
)
|
||||
persistence = PersistenceManager(application)
|
||||
application.persistence_mgr = persistence
|
||||
|
||||
await persistence.initialize()
|
||||
try:
|
||||
application.workspace_service = WorkspaceService(
|
||||
application,
|
||||
instance_uuid=instance_uuid,
|
||||
)
|
||||
application.workspace_collaboration_service = WorkspaceCollaborationService(
|
||||
application,
|
||||
application.workspace_service,
|
||||
)
|
||||
application.user_service = UserService(application)
|
||||
|
||||
quart_app = Quart(__name__)
|
||||
await UserRouterGroup(application, quart_app).initialize()
|
||||
await WorkspacesRouterGroup(application, quart_app).initialize()
|
||||
await SystemRouterGroup(application, quart_app).initialize()
|
||||
client = quart_app.test_client()
|
||||
|
||||
initialization = await client.get('/api/v1/user/init')
|
||||
assert initialization.status_code == 200
|
||||
assert (await initialization.get_json())['data'] == {'initialized': False}
|
||||
|
||||
initialized = await client.post(
|
||||
'/api/v1/user/init',
|
||||
json={'user': 'owner@example.com', 'password': 'owner-password'},
|
||||
)
|
||||
assert initialized.status_code == 200
|
||||
|
||||
authenticated = await client.post(
|
||||
'/api/v1/user/auth',
|
||||
json={'user': 'owner@example.com', 'password': 'owner-password'},
|
||||
)
|
||||
assert authenticated.status_code == 200
|
||||
token = (await authenticated.get_json())['data']['token']
|
||||
|
||||
bootstrap = await client.get(
|
||||
'/api/v1/workspaces/bootstrap',
|
||||
headers=_authorization(token),
|
||||
)
|
||||
assert bootstrap.status_code == 200
|
||||
bootstrap_workspaces = (await bootstrap.get_json())['data']['workspaces']
|
||||
assert len(bootstrap_workspaces) == 1
|
||||
bootstrap_access = bootstrap_workspaces[0]
|
||||
workspace_uuid = bootstrap_access['workspace']['uuid']
|
||||
assert bootstrap_access['workspace'] == {
|
||||
'uuid': workspace_uuid,
|
||||
'instance_uuid': instance_uuid,
|
||||
'name': 'Default Workspace',
|
||||
'slug': 'default',
|
||||
'type': 'team',
|
||||
'status': 'active',
|
||||
'source': 'local',
|
||||
}
|
||||
assert bootstrap_access['membership']['email'] == 'owner@example.com'
|
||||
assert bootstrap_access['membership']['role'] == 'owner'
|
||||
assert 'workspace.update' in bootstrap_access['permissions']
|
||||
|
||||
current = await client.get(
|
||||
'/api/v1/workspaces/current',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
)
|
||||
assert current.status_code == 200
|
||||
current_data = (await current.get_json())['data']
|
||||
assert current_data['workspace']['uuid'] == workspace_uuid
|
||||
assert current_data['membership']['account_uuid'] == bootstrap_access['membership']['account_uuid']
|
||||
assert current_data['membership']['role'] == 'owner'
|
||||
|
||||
user_info = await client.get(
|
||||
'/api/v1/user/info',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
)
|
||||
assert user_info.status_code == 200
|
||||
assert (await user_info.get_json())['data'] == {
|
||||
'account_uuid': bootstrap_access['membership']['account_uuid'],
|
||||
'user': 'owner@example.com',
|
||||
'account_type': 'local',
|
||||
'has_password': True,
|
||||
}
|
||||
|
||||
initial_system_info = await client.get(
|
||||
'/api/v1/system/info',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
)
|
||||
assert initial_system_info.status_code == 200
|
||||
assert (await initial_system_info.get_json())['data']['wizard_status'] == 'none'
|
||||
assert (await initial_system_info.get_json())['data']['wizard_progress'] is None
|
||||
|
||||
progress = {'step': 2, 'selected_adapter': 'telegram', 'bot_saved': False}
|
||||
updated_progress = await client.put(
|
||||
'/api/v1/system/wizard/progress',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
json=progress,
|
||||
)
|
||||
assert updated_progress.status_code == 200
|
||||
|
||||
persisted_progress = await persistence.execute_async(
|
||||
sa.select(WorkspaceMetadata.value).where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_progress',
|
||||
)
|
||||
)
|
||||
assert json.loads(persisted_progress.scalar_one()) == progress
|
||||
|
||||
system_info_with_progress = await client.get(
|
||||
'/api/v1/system/info',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
)
|
||||
assert system_info_with_progress.status_code == 200
|
||||
progress_data = (await system_info_with_progress.get_json())['data']
|
||||
assert progress_data['wizard_status'] == 'none'
|
||||
assert progress_data['wizard_progress'] == progress
|
||||
|
||||
completed = await client.post(
|
||||
'/api/v1/system/wizard/completed',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
json={'status': 'completed'},
|
||||
)
|
||||
assert completed.status_code == 200
|
||||
|
||||
completed_system_info = await client.get(
|
||||
'/api/v1/system/info',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
)
|
||||
assert completed_system_info.status_code == 200
|
||||
completed_data = (await completed_system_info.get_json())['data']
|
||||
assert completed_data['wizard_status'] == 'completed'
|
||||
assert completed_data['wizard_progress'] is None
|
||||
|
||||
rejected_workspace = await client.post(
|
||||
'/api/v1/workspaces',
|
||||
headers=_authorization(token, workspace_uuid),
|
||||
json={'name': 'Second Workspace'},
|
||||
)
|
||||
assert rejected_workspace.status_code == 403
|
||||
rejected_data = await rejected_workspace.get_json()
|
||||
assert rejected_data['code'] == 'edition_limit'
|
||||
|
||||
persisted_wizard_status = await persistence.execute_async(
|
||||
sa.select(WorkspaceMetadata.value).where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_status',
|
||||
)
|
||||
)
|
||||
assert persisted_wizard_status.scalar_one() == 'completed'
|
||||
finally:
|
||||
await persistence.get_db_engine().dispose()
|
||||
@@ -86,6 +86,7 @@ async def plugin_security_api(plugin_module):
|
||||
}
|
||||
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
|
||||
@@ -112,6 +113,7 @@ async def plugin_security_api(plugin_module):
|
||||
persistence_result = Mock()
|
||||
persistence_result.scalar_one_or_none.return_value = RAW_CONFIG
|
||||
application.persistence_mgr.execute_async = AsyncMock(return_value=persistence_result)
|
||||
application.persistence_mgr.tenant_uow = None
|
||||
|
||||
quart_app = quart.Quart(__name__)
|
||||
router = plugin_module.PluginsRouterGroup(application, quart_app)
|
||||
@@ -246,3 +248,25 @@ async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
|
||||
assert response.status_code == 403
|
||||
assert (await response.get_json())['code'] == 'permission_denied'
|
||||
application.plugin_connector.get_plugin_logs.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_install_rejects_internal_asset_url_before_task_creation(
|
||||
plugin_security_api,
|
||||
):
|
||||
application, client, _ = plugin_security_api
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/plugins/install/github',
|
||||
headers=_headers('manager-token'),
|
||||
json={
|
||||
'asset_url': 'http://169.254.169.254/latest/meta-data',
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg']
|
||||
application.task_mgr.create_user_task.assert_not_called()
|
||||
|
||||
@@ -25,6 +25,8 @@ async def space_oauth_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.persistence_mgr = None
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||
side_effect=lambda purpose, **_: f'opaque-{purpose}-state'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -13,11 +14,13 @@ from langbot.pkg.api.http.controller.groups.workspaces import (
|
||||
InvitationsRouterGroup,
|
||||
WorkspacesRouterGroup,
|
||||
)
|
||||
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.apikeys import ApiKeysRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.api.http.service.apikey import ApiKeyService
|
||||
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
Workspace,
|
||||
@@ -25,6 +28,7 @@ from langbot.pkg.entity.persistence.workspace import (
|
||||
WorkspaceInvitation,
|
||||
WorkspaceMembership,
|
||||
)
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
||||
@@ -33,28 +37,6 @@ from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
class _PersistenceManager:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def get_db_engine(self):
|
||||
return self.engine
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.execute(*args, **kwargs)
|
||||
await connection.commit()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def serialize_model(model, row, masked_columns=()):
|
||||
return {
|
||||
column.name: getattr(row, column.name)
|
||||
for column in model.__table__.columns
|
||||
if column.name not in masked_columns
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def workspace_api(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-api.db"}')
|
||||
@@ -62,7 +44,8 @@ async def workspace_api(tmp_path):
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
application = SimpleNamespace()
|
||||
application.persistence_mgr = _PersistenceManager(engine)
|
||||
application.persistence_mgr = PersistenceManager(application)
|
||||
application.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
application.instance_config = SimpleNamespace(
|
||||
data={
|
||||
'system': {
|
||||
@@ -85,19 +68,27 @@ async def workspace_api(tmp_path):
|
||||
application.user_service = UserService(application)
|
||||
application.apikey_service = ApiKeyService(application)
|
||||
|
||||
owner = await application.user_service.create_initial_account(
|
||||
'owner@example.com',
|
||||
'owner-password',
|
||||
)
|
||||
owner_token = await application.user_service.generate_jwt_token(owner)
|
||||
|
||||
quart_app = Quart(__name__)
|
||||
await WorkspacesRouterGroup(application, quart_app).initialize()
|
||||
await InvitationsRouterGroup(application, quart_app).initialize()
|
||||
await ApiKeysRouterGroup(application, quart_app).initialize()
|
||||
await UserRouterGroup(application, quart_app).initialize()
|
||||
await SystemRouterGroup(application, quart_app).initialize()
|
||||
|
||||
yield application, quart_app.test_client(), engine, owner_token
|
||||
client = quart_app.test_client()
|
||||
init_response = await client.post(
|
||||
'/api/v1/user/init',
|
||||
json={'user': 'owner@example.com', 'password': 'owner-password'},
|
||||
)
|
||||
assert init_response.status_code == 200
|
||||
auth_response = await client.post(
|
||||
'/api/v1/user/auth',
|
||||
json={'user': 'owner@example.com', 'password': 'owner-password'},
|
||||
)
|
||||
assert auth_response.status_code == 200
|
||||
owner_token = (await auth_response.get_json())['data']['token']
|
||||
|
||||
yield application, client, engine, owner_token
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -108,6 +99,56 @@ def _auth(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
|
||||
return headers
|
||||
|
||||
|
||||
async def test_fresh_sqlite_login_returns_current_workspace_and_user_info(workspace_api):
|
||||
_, client, _, owner_token = workspace_api
|
||||
|
||||
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
||||
assert current_response.status_code == 200
|
||||
current = (await current_response.get_json())['data']
|
||||
assert current['workspace']['uuid']
|
||||
assert current['membership']['email'] == 'owner@example.com'
|
||||
assert current['membership']['role'] == 'owner'
|
||||
|
||||
info_response = await client.get('/api/v1/user/info', headers=_auth(owner_token))
|
||||
assert info_response.status_code == 200
|
||||
info = (await info_response.get_json())['data']
|
||||
assert info['account_uuid'] == current['membership']['account_uuid']
|
||||
assert info['user'] == 'owner@example.com'
|
||||
|
||||
|
||||
async def test_authenticated_system_info_reads_workspace_wizard_metadata(workspace_api):
|
||||
application, client, _, owner_token = workspace_api
|
||||
|
||||
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
||||
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
|
||||
progress = {'step': 3, 'selected_adapter': 'telegram'}
|
||||
await application.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(WorkspaceMetadata),
|
||||
[
|
||||
{
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'key': 'wizard_status',
|
||||
'value': 'completed',
|
||||
},
|
||||
{
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'key': 'wizard_progress',
|
||||
'value': json.dumps(progress),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/system/info',
|
||||
headers=_auth(owner_token, workspace_uuid),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = (await response.get_json())['data']
|
||||
assert data['wizard_status'] == 'completed'
|
||||
assert data['wizard_progress'] == progress
|
||||
|
||||
|
||||
async def test_owner_invites_second_account_and_secret_is_not_persisted(workspace_api):
|
||||
application, client, engine, owner_token = workspace_api
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
@@ -149,6 +150,27 @@ class TestSQLiteMigrationUpgrade:
|
||||
rev2 = await get_alembic_current(sqlite_engine)
|
||||
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_0012_adds_knowledge_base_embedding_dimension(self, sqlite_engine):
|
||||
"""The PostgreSQL pgvector revision also evolves the OSS ORM schema."""
|
||||
|
||||
async with sqlite_engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
'CREATE TABLE knowledge_bases ('
|
||||
'uuid VARCHAR(255) PRIMARY KEY, workspace_uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL)'
|
||||
)
|
||||
|
||||
await run_alembic_stamp(sqlite_engine, '0012_plugin_identity')
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
async with sqlite_engine.connect() as conn:
|
||||
columns = await conn.run_sync(
|
||||
lambda sync_conn: {
|
||||
item['name'] for item in sqlalchemy.inspect(sync_conn).get_columns('knowledge_bases')
|
||||
}
|
||||
)
|
||||
assert 'embedding_dimension' in columns
|
||||
|
||||
|
||||
class TestSQLiteMigrationFreshDatabase:
|
||||
"""Tests for fresh database workflow."""
|
||||
|
||||
@@ -16,19 +16,30 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import typing
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from quart import Quart
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TENANT_POLICY_NAME, TENANT_TABLE_COLUMNS, TenantUnitOfWork
|
||||
from langbot.pkg.persistence.tenant_uow import (
|
||||
TENANT_POLICY_NAME,
|
||||
TENANT_TABLE_COLUMNS,
|
||||
TenantUnitOfWork,
|
||||
TransactionRollbackOnlyError,
|
||||
)
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
run_alembic_upgrade,
|
||||
run_alembic_stamp,
|
||||
@@ -39,6 +50,32 @@ from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.workspace.collaboration import normalize_email
|
||||
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
|
||||
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.controller import group as http_group
|
||||
from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
|
||||
from langbot.pkg.api.http.context import ExecutionContext, RequestContext
|
||||
from langbot.pkg.api.http.service.apikey import ApiKeyService
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.api.mcp.context import get_request_context as get_mcp_request_context
|
||||
from langbot.pkg.api.mcp.mount import MCPMount
|
||||
from langbot.pkg.entity.persistence.apikey import ApiKey
|
||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringFeedback
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
Workspace,
|
||||
WorkspaceExecutionState,
|
||||
WorkspaceMembership,
|
||||
)
|
||||
from langbot.pkg.platform.botmgr import PlatformManager
|
||||
from langbot.pkg.pipeline.pipelinemgr import PipelineManager
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPLoader
|
||||
from langbot.pkg.rag.knowledge.kbmgr import RAGManager
|
||||
|
||||
from .resource_migration_support import TENANT_TABLES, create_legacy_resource_schema
|
||||
|
||||
@@ -54,6 +91,42 @@ def _get_script_head() -> str:
|
||||
return ScriptDirectory.from_config(cfg).get_current_head()
|
||||
|
||||
|
||||
async def _grant_runtime_role_business_objects(
|
||||
conn: AsyncConnection,
|
||||
role_name: str,
|
||||
quote: typing.Callable[[str], str],
|
||||
) -> None:
|
||||
"""Mirror the release job's object ACLs without overgranting Alembic."""
|
||||
|
||||
business_tables = tuple(sorted({table.name for table in Base.metadata.tables.values()} | {'langbot_vectors'}))
|
||||
quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables)
|
||||
await conn.execute(text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(role_name)}'))
|
||||
await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(role_name)}'))
|
||||
sequence_query = text(
|
||||
"""
|
||||
SELECT DISTINCT sequence.relname
|
||||
FROM pg_class sequence
|
||||
JOIN pg_namespace sequence_namespace ON sequence_namespace.oid = sequence.relnamespace
|
||||
JOIN pg_depend dependency
|
||||
ON dependency.classid = 'pg_class'::regclass
|
||||
AND dependency.objid = sequence.oid
|
||||
AND dependency.refclassid = 'pg_class'::regclass
|
||||
AND dependency.deptype IN ('a', 'i')
|
||||
JOIN pg_class business_table ON business_table.oid = dependency.refobjid
|
||||
JOIN pg_namespace table_namespace ON table_namespace.oid = business_table.relnamespace
|
||||
WHERE sequence.relkind = 'S'
|
||||
AND sequence_namespace.nspname = 'public'
|
||||
AND table_namespace.nspname = 'public'
|
||||
AND business_table.relname IN :table_names
|
||||
ORDER BY sequence.relname
|
||||
"""
|
||||
).bindparams(sa.bindparam('table_names', expanding=True))
|
||||
sequence_names = tuple((await conn.execute(sequence_query, {'table_names': business_tables})).scalars().all())
|
||||
if sequence_names:
|
||||
quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names)
|
||||
await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(role_name)}'))
|
||||
|
||||
|
||||
def _application_for_postgres_url(postgres_url: str, logger_name: str) -> SimpleNamespace:
|
||||
url = sa.engine.make_url(postgres_url)
|
||||
return SimpleNamespace(
|
||||
@@ -115,15 +188,20 @@ async def postgres_engine(postgres_url):
|
||||
@pytest.fixture
|
||||
async def clean_tables(postgres_engine):
|
||||
"""Drop all tables before and after each test for isolation."""
|
||||
# Drop all tables before test
|
||||
async with postgres_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
async def drop_all_tables() -> None:
|
||||
# Alembic can create tables (notably langbot_vectors) outside the ORM
|
||||
# metadata, and legacy migration tests intentionally alter constraints.
|
||||
# Reflect the dedicated test schema instead of relying on stale ORM DDL.
|
||||
async with postgres_engine.begin() as conn:
|
||||
table_names = await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
for table_name in table_names:
|
||||
await conn.execute(text(f'DROP TABLE {quote(table_name)} CASCADE'))
|
||||
|
||||
await drop_all_tables()
|
||||
yield
|
||||
|
||||
# Drop all tables after test
|
||||
async with postgres_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await drop_all_tables()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -442,10 +520,16 @@ class TestPostgreSQLResourceTenancyMigration:
|
||||
await conn.execute(
|
||||
text(
|
||||
'INSERT INTO plugin_settings '
|
||||
'(workspace_uuid, plugin_author, plugin_name, enabled) '
|
||||
"VALUES (:workspace_uuid, 'author', 'plugin', true)"
|
||||
'(workspace_uuid, plugin_author, plugin_name, enabled, '
|
||||
'installation_uuid, artifact_digest, runtime_revision) '
|
||||
"VALUES (:workspace_uuid, 'author', 'plugin', true, "
|
||||
':installation_uuid, :artifact_digest, 1)'
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
{
|
||||
'workspace_uuid': second_workspace_uuid,
|
||||
'installation_uuid': str(uuid.uuid4()),
|
||||
'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
@@ -556,10 +640,7 @@ class TestPostgreSQLTenantRuntime:
|
||||
)
|
||||
)
|
||||
await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}'))
|
||||
await conn.execute(
|
||||
text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {quote(role_name)}')
|
||||
)
|
||||
await conn.execute(text(f'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO {quote(role_name)}'))
|
||||
await _grant_runtime_role_business_objects(conn, role_name, quote)
|
||||
created_roles.append(role_name)
|
||||
|
||||
try:
|
||||
@@ -703,6 +784,31 @@ class TestPostgreSQLTenantRuntime:
|
||||
cloud_manager.create_tables.assert_not_awaited()
|
||||
cloud_manager._run_alembic_migrations.assert_not_awaited()
|
||||
|
||||
with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
duplicate_statement = sa.insert(WorkspaceMetadata).values(
|
||||
workspace_uuid=workspace_a,
|
||||
key='rollback-only-unique',
|
||||
value='must-not-commit',
|
||||
)
|
||||
await cloud_manager.execute_async(duplicate_statement)
|
||||
after_commit_gate = cloud_manager.create_after_commit_gate()
|
||||
assert after_commit_gate is not None
|
||||
try:
|
||||
await cloud_manager.execute_async(duplicate_statement)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
assert after_commit_gate.cancelled()
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
assert (
|
||||
await cloud_manager.execute_async(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(WorkspaceMetadata)
|
||||
.where(WorkspaceMetadata.key == 'rollback-only-unique')
|
||||
)
|
||||
).scalar_one() == 0
|
||||
|
||||
superuser_manager = PersistenceManager(
|
||||
_application_for_postgres_url(postgres_url, 'postgres-superuser-runtime-test'),
|
||||
mode=PersistenceMode.CLOUD_RUNTIME,
|
||||
@@ -743,3 +849,644 @@ class TestPostgreSQLTenantRuntime:
|
||||
for role_name in reversed(created_roles):
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
|
||||
await conn.execute(text(f'DROP ROLE {quote(role_name)}'))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_discovery_http_and_transaction_contract(
|
||||
self,
|
||||
postgres_url,
|
||||
postgres_engine,
|
||||
clean_tables,
|
||||
clean_alembic_version,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Exercise the complete request/discovery path as a non-owner role."""
|
||||
|
||||
instance_uuid = 'cloud-request-rls-test'
|
||||
other_instance_uuid = 'other-cloud-instance'
|
||||
workspace_a = '31000000-0000-0000-0000-000000000001'
|
||||
workspace_b = '32000000-0000-0000-0000-000000000002'
|
||||
workspace_other = '33000000-0000-0000-0000-000000000003'
|
||||
workspace_fenced = '34000000-0000-0000-0000-000000000004'
|
||||
account_a_uuid = '41000000-0000-0000-0000-000000000001'
|
||||
shared_account_uuid = '42000000-0000-0000-0000-000000000002'
|
||||
active_secret = 'lbk_active-request-key'
|
||||
revoked_secret = 'lbk_revoked-request-key'
|
||||
expired_secret = 'lbk_expired-request-key'
|
||||
role_name = f'lb_request_{uuid.uuid4().hex[:12]}'
|
||||
role_password = f'Lb{uuid.uuid4().hex}'
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
managers: list[PersistenceManager] = []
|
||||
role_created = False
|
||||
|
||||
_restore_postgres_manager_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
|
||||
release_manager = PersistenceManager(
|
||||
_application_for_postgres_url(postgres_url, 'postgres-request-release-test'),
|
||||
mode=PersistenceMode.RELEASE_MIGRATION,
|
||||
)
|
||||
managers.append(release_manager)
|
||||
|
||||
def role_url() -> str:
|
||||
return (
|
||||
sa.engine.make_url(postgres_url)
|
||||
.set(username=role_name, password=role_password)
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
|
||||
async def seed_workspace(
|
||||
workspace_uuid: str,
|
||||
*,
|
||||
target_instance: str,
|
||||
state: str = 'active',
|
||||
write_fenced: bool = False,
|
||||
) -> None:
|
||||
async with release_manager.tenant_uow(workspace_uuid) as uow:
|
||||
uow.session.add(
|
||||
Workspace(
|
||||
uuid=workspace_uuid,
|
||||
instance_uuid=target_instance,
|
||||
name=workspace_uuid[-4:],
|
||||
slug=f'workspace-{workspace_uuid[-4:]}',
|
||||
type='team',
|
||||
status='active',
|
||||
source='cloud_projection',
|
||||
projection_revision=1,
|
||||
)
|
||||
)
|
||||
await uow.session.flush()
|
||||
uow.session.add(
|
||||
WorkspaceExecutionState(
|
||||
workspace_uuid=workspace_uuid,
|
||||
instance_uuid=target_instance,
|
||||
active_generation=1,
|
||||
state=state,
|
||||
write_fenced=write_fenced,
|
||||
source='cloud',
|
||||
desired_state_revision=1,
|
||||
)
|
||||
)
|
||||
uow.session.add(
|
||||
WorkspaceMetadata(
|
||||
workspace_uuid=workspace_uuid,
|
||||
key='tenant-marker',
|
||||
value=workspace_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await release_manager.initialize()
|
||||
async with release_manager.get_db_engine().begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(User),
|
||||
[
|
||||
{
|
||||
'uuid': account_a_uuid,
|
||||
'user': 'account-a@example.com',
|
||||
'normalized_email': 'account-a@example.com',
|
||||
'password': 'closed-directory',
|
||||
'account_type': 'local',
|
||||
'status': 'active',
|
||||
'source': 'cloud_projection',
|
||||
'projection_revision': 1,
|
||||
},
|
||||
{
|
||||
'uuid': shared_account_uuid,
|
||||
'user': 'shared@example.com',
|
||||
'normalized_email': 'shared@example.com',
|
||||
'password': 'closed-directory',
|
||||
'account_type': 'local',
|
||||
'status': 'active',
|
||||
'source': 'cloud_projection',
|
||||
'projection_revision': 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
await seed_workspace(workspace_a, target_instance=instance_uuid)
|
||||
await seed_workspace(workspace_b, target_instance=instance_uuid)
|
||||
await seed_workspace(workspace_other, target_instance=other_instance_uuid)
|
||||
await seed_workspace(
|
||||
workspace_fenced,
|
||||
target_instance=instance_uuid,
|
||||
write_fenced=True,
|
||||
)
|
||||
|
||||
async with release_manager.tenant_uow(workspace_a) as uow:
|
||||
uow.session.add_all(
|
||||
[
|
||||
WorkspaceMembership(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_a,
|
||||
account_uuid=account_a_uuid,
|
||||
role='owner',
|
||||
status='active',
|
||||
projection_revision=1,
|
||||
),
|
||||
WorkspaceMembership(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_a,
|
||||
account_uuid=shared_account_uuid,
|
||||
role='viewer',
|
||||
status='active',
|
||||
projection_revision=1,
|
||||
),
|
||||
ApiKey(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_a,
|
||||
name='active-key',
|
||||
key_hash=hashlib.sha256(active_secret.encode()).hexdigest(),
|
||||
scopes=[Permission.WORKSPACE_VIEW.value],
|
||||
status='active',
|
||||
),
|
||||
]
|
||||
)
|
||||
async with release_manager.tenant_uow(workspace_b) as uow:
|
||||
uow.session.add_all(
|
||||
[
|
||||
WorkspaceMembership(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_b,
|
||||
account_uuid=shared_account_uuid,
|
||||
role='viewer',
|
||||
status='active',
|
||||
projection_revision=1,
|
||||
),
|
||||
ApiKey(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_b,
|
||||
name='revoked-key',
|
||||
key_hash=hashlib.sha256(revoked_secret.encode()).hexdigest(),
|
||||
scopes=[Permission.WORKSPACE_VIEW.value],
|
||||
status='revoked',
|
||||
),
|
||||
ApiKey(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_b,
|
||||
name='expired-key',
|
||||
key_hash=hashlib.sha256(expired_secret.encode()).hexdigest(),
|
||||
scopes=[Permission.WORKSPACE_VIEW.value],
|
||||
status='active',
|
||||
expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
- datetime.timedelta(minutes=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f"CREATE ROLE {quote(role_name)} LOGIN PASSWORD '{role_password}'"))
|
||||
role_created = True
|
||||
await conn.execute(
|
||||
text(
|
||||
f'GRANT CONNECT ON DATABASE {quote(sa.engine.make_url(postgres_url).database)} '
|
||||
f'TO {quote(role_name)}'
|
||||
)
|
||||
)
|
||||
await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}'))
|
||||
await _grant_runtime_role_business_objects(conn, role_name, quote)
|
||||
|
||||
runtime_application = _application_for_postgres_url(role_url(), 'postgres-request-runtime-test')
|
||||
runtime_application.instance_config.data.update(
|
||||
{
|
||||
'system': {
|
||||
'jwt': {'secret': 'postgres-request-jwt', 'expire': 3600},
|
||||
},
|
||||
'api': {'global_api_key': ''},
|
||||
}
|
||||
)
|
||||
runtime_application.logger = logging.getLogger('postgres-request-runtime-test')
|
||||
cloud_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
managers.append(cloud_manager)
|
||||
await cloud_manager.initialize()
|
||||
|
||||
runtime_application.persistence_mgr = cloud_manager
|
||||
cloud_manager.ap = runtime_application
|
||||
runtime_application.workspace_service = WorkspaceService(
|
||||
runtime_application,
|
||||
policy=CloudWorkspacePolicy(),
|
||||
instance_uuid=instance_uuid,
|
||||
)
|
||||
runtime_application.workspace_collaboration_service = WorkspaceCollaborationService(
|
||||
runtime_application,
|
||||
runtime_application.workspace_service,
|
||||
policy=CloudWorkspacePolicy(),
|
||||
)
|
||||
runtime_application.user_service = UserService(runtime_application)
|
||||
runtime_application.apikey_service = ApiKeyService(runtime_application)
|
||||
runtime_application.monitoring_service = MonitoringService(runtime_application)
|
||||
|
||||
bindings = await runtime_application.workspace_service.list_active_execution_bindings()
|
||||
assert {binding.workspace_uuid for binding in bindings} == {workspace_a, workspace_b}
|
||||
|
||||
# No scope is an application error before SQL reaches PostgreSQL.
|
||||
with pytest.raises(RuntimeError, match='explicit Workspace or discovery'):
|
||||
await cloud_manager.execute_async(sa.select(WorkspaceMetadata))
|
||||
|
||||
# Discovery exposes only its index rows and cannot write.
|
||||
with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
|
||||
async with cloud_manager.account_discovery_uow(shared_account_uuid) as discovery:
|
||||
assert set(
|
||||
(
|
||||
await discovery.session.scalars(
|
||||
sa.select(WorkspaceMembership.workspace_uuid).order_by(
|
||||
WorkspaceMembership.workspace_uuid
|
||||
)
|
||||
)
|
||||
).all()
|
||||
) == {workspace_a, workspace_b}
|
||||
with pytest.raises(sa.exc.DBAPIError):
|
||||
await discovery.session.execute(
|
||||
sa.insert(WorkspaceMembership).values(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_a,
|
||||
account_uuid=shared_account_uuid,
|
||||
role='viewer',
|
||||
status='active',
|
||||
projection_revision=1,
|
||||
)
|
||||
)
|
||||
|
||||
async with cloud_manager.api_key_discovery_uow(
|
||||
hashlib.sha256(active_secret.encode()).hexdigest()
|
||||
) as discovery:
|
||||
assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) == workspace_a
|
||||
update_result = await discovery.session.execute(
|
||||
sa.update(ApiKey)
|
||||
.where(ApiKey.key_hash == hashlib.sha256(active_secret.encode()).hexdigest())
|
||||
.values(name='discovery-must-not-write')
|
||||
)
|
||||
assert update_result.rowcount == 0
|
||||
async with cloud_manager.api_key_discovery_uow(
|
||||
hashlib.sha256(revoked_secret.encode()).hexdigest()
|
||||
) as discovery:
|
||||
assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) is None
|
||||
async with cloud_manager.api_key_discovery_uow(
|
||||
hashlib.sha256(expired_secret.encode()).hexdigest()
|
||||
) as discovery:
|
||||
assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) is None
|
||||
|
||||
async with cloud_manager.instance_discovery_uow(instance_uuid) as discovery:
|
||||
assert set(
|
||||
(await discovery.session.scalars(sa.select(WorkspaceExecutionState.workspace_uuid))).all()
|
||||
) == {workspace_a, workspace_b}
|
||||
update_result = await discovery.session.execute(
|
||||
sa.update(WorkspaceExecutionState)
|
||||
.where(WorkspaceExecutionState.workspace_uuid == workspace_a)
|
||||
.values(write_fenced=True)
|
||||
)
|
||||
assert update_result.rowcount == 0
|
||||
# Instance discovery deliberately cannot see business rows.
|
||||
assert (await discovery.session.execute(sa.select(WorkspaceMetadata))).all() == []
|
||||
|
||||
platform_manager = PlatformManager(runtime_application)
|
||||
platform_manager._load_workspace_bots = AsyncMock()
|
||||
await platform_manager.load_bots_from_db()
|
||||
assert {call.args[0] for call in platform_manager._load_workspace_bots.await_args_list} == {
|
||||
workspace_a,
|
||||
workspace_b,
|
||||
}
|
||||
|
||||
# Every startup cache loader traverses the instance index first,
|
||||
# then reads business resources in one tenant transaction at a time.
|
||||
await ModelManager(runtime_application).load_models_from_db()
|
||||
await PipelineManager(runtime_application).load_pipelines_from_db()
|
||||
await MCPLoader(runtime_application).load_mcp_servers_from_db()
|
||||
await RAGManager(runtime_application).load_knowledge_bases_from_db()
|
||||
|
||||
# An omitted Workspace predicate remains isolated by RLS.
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
values = (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
|
||||
assert values == [workspace_a]
|
||||
|
||||
async def read_tenant_repeatedly(workspace_uuid: str) -> list[str]:
|
||||
observed: list[str] = []
|
||||
for _ in range(5):
|
||||
async with cloud_manager.tenant_uow(workspace_uuid):
|
||||
observed.extend(
|
||||
(await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
return observed
|
||||
|
||||
observed_a, observed_b = await asyncio.gather(
|
||||
read_tenant_repeatedly(workspace_a),
|
||||
read_tenant_repeatedly(workspace_b),
|
||||
)
|
||||
assert observed_a == [workspace_a] * 5
|
||||
assert observed_b == [workspace_b] * 5
|
||||
|
||||
accesses = await runtime_application.workspace_collaboration_service.list_account_workspaces(
|
||||
shared_account_uuid
|
||||
)
|
||||
assert {access.workspace.uuid for access in accesses} == {workspace_a, workspace_b}
|
||||
assert await runtime_application.apikey_service.authenticate_api_key(revoked_secret) is None
|
||||
assert await runtime_application.apikey_service.authenticate_api_key(expired_secret) is None
|
||||
active_identity = await runtime_application.apikey_service.authenticate_api_key(active_secret)
|
||||
assert active_identity is not None
|
||||
assert active_identity.workspace_uuid == workspace_a
|
||||
|
||||
async def record_feedback(feedback_type: int) -> str | None:
|
||||
async with cloud_manager.tenant_scope(workspace_a):
|
||||
return await runtime_application.monitoring_service.record_feedback(
|
||||
ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_a,
|
||||
placement_generation=1,
|
||||
),
|
||||
feedback_id='concurrent-feedback',
|
||||
feedback_type=feedback_type,
|
||||
)
|
||||
|
||||
feedback_ids = await asyncio.gather(record_feedback(1), record_feedback(2))
|
||||
assert feedback_ids[0] == feedback_ids[1]
|
||||
async with cloud_manager.tenant_uow(workspace_a) as uow:
|
||||
feedback_rows = (
|
||||
(
|
||||
await uow.execute(
|
||||
sa.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == 'concurrent-feedback')
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(feedback_rows) == 1
|
||||
assert feedback_rows[0].feedback_type in {1, 2}
|
||||
await uow.execute(
|
||||
sa.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == 'concurrent-feedback')
|
||||
)
|
||||
|
||||
class TenantRuntimeRouter(http_group.RouterGroup):
|
||||
name = 'postgres-tenant-runtime'
|
||||
path = '/tenant-runtime'
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/account', permission=Permission.WORKSPACE_VIEW)
|
||||
async def account_route(request_context: RequestContext):
|
||||
assert self.ap.persistence_mgr.current_session() is None
|
||||
if self.quart_app.config.get('FORCE_HANDLER_FAILURE'):
|
||||
async with self.ap.persistence_mgr.tenant_uow(request_context.workspace_uuid):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sa.insert(WorkspaceMetadata).values(
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
key='rolled-back-handler',
|
||||
value='must-not-commit',
|
||||
)
|
||||
)
|
||||
raise RuntimeError('forced handler failure')
|
||||
values = (
|
||||
(await self.ap.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert self.ap.persistence_mgr.current_session() is None
|
||||
return self.success(data={'workspace_uuid': request_context.workspace_uuid, 'values': values})
|
||||
|
||||
@self.route('/key', auth_type=http_group.AuthType.API_KEY)
|
||||
async def key_route(request_context: RequestContext):
|
||||
values = (
|
||||
(await self.ap.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return self.success(data={'workspace_uuid': request_context.workspace_uuid, 'values': values})
|
||||
|
||||
@self.route('/bootstrap', auth_type=http_group.AuthType.ACCOUNT_TOKEN)
|
||||
async def bootstrap_route(user_email: str):
|
||||
account = await self.ap.user_service.get_user_by_email(user_email)
|
||||
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
|
||||
return self.success(data=sorted(access.workspace.uuid for access in accesses))
|
||||
|
||||
quart_app = Quart(__name__)
|
||||
await TenantRuntimeRouter(runtime_application, quart_app).initialize()
|
||||
|
||||
webhook_bot_uuid = str(uuid.uuid4())
|
||||
|
||||
class TenantAwareWebhookAdapter:
|
||||
async def handle_unified_webhook(self, **_kwargs):
|
||||
assert cloud_manager.current_session() is None
|
||||
await asyncio.sleep(0)
|
||||
assert cloud_manager.current_session() is None
|
||||
values = (
|
||||
(
|
||||
await cloud_manager.execute_async(
|
||||
sa.select(WorkspaceMetadata.value).where(WorkspaceMetadata.key == 'tenant-marker')
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert cloud_manager.current_session() is None
|
||||
return {'values': values}
|
||||
|
||||
runtime_application.platform_mgr = SimpleNamespace(
|
||||
resolve_public_bot=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace_uuid=workspace_a,
|
||||
placement_generation=1,
|
||||
enable=True,
|
||||
adapter=TenantAwareWebhookAdapter(),
|
||||
)
|
||||
)
|
||||
)
|
||||
await WebhookRouterGroup(runtime_application, quart_app).initialize()
|
||||
await SystemRouterGroup(runtime_application, quart_app).initialize()
|
||||
client = quart_app.test_client()
|
||||
account_a = await runtime_application.user_service.get_user_by_uuid(account_a_uuid)
|
||||
shared_account = await runtime_application.user_service.get_user_by_uuid(shared_account_uuid)
|
||||
assert account_a is not None and shared_account is not None
|
||||
account_token = await runtime_application.user_service.generate_jwt_token(account_a)
|
||||
shared_token = await runtime_application.user_service.generate_jwt_token(shared_account)
|
||||
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
await cloud_manager.execute_async(
|
||||
sa.insert(WorkspaceMetadata).values(
|
||||
workspace_uuid=workspace_a,
|
||||
key='wizard_status',
|
||||
value='completed',
|
||||
)
|
||||
)
|
||||
response = await client.get(
|
||||
'/api/v1/system/info',
|
||||
headers={
|
||||
'Authorization': f'Bearer {account_token}',
|
||||
'X-Workspace-Id': workspace_a,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['wizard_status'] == 'completed'
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
await cloud_manager.execute_async(
|
||||
sa.delete(WorkspaceMetadata).where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace_a,
|
||||
WorkspaceMetadata.key == 'wizard_status',
|
||||
)
|
||||
)
|
||||
|
||||
response = await client.post(f'/bots/{webhook_bot_uuid}')
|
||||
assert response.status_code == 200
|
||||
assert await response.get_json() == {'values': [workspace_a]}
|
||||
|
||||
response = await client.get(
|
||||
'/tenant-runtime/account',
|
||||
headers={
|
||||
'Authorization': f'Bearer {account_token}',
|
||||
'X-Workspace-Id': workspace_a,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data'] == {
|
||||
'workspace_uuid': workspace_a,
|
||||
'values': [workspace_a],
|
||||
}
|
||||
|
||||
response = await client.get(
|
||||
'/tenant-runtime/account',
|
||||
headers={
|
||||
'Authorization': f'Bearer {account_token}',
|
||||
'X-Workspace-Id': workspace_b,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
response = await client.get(
|
||||
'/tenant-runtime/account',
|
||||
headers={'Authorization': f'Bearer {shared_token}'},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
response = await client.get(
|
||||
'/tenant-runtime/bootstrap',
|
||||
headers={'Authorization': f'Bearer {shared_token}'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data'] == [workspace_a, workspace_b]
|
||||
|
||||
response = await client.get(
|
||||
'/tenant-runtime/key',
|
||||
headers={'X-API-Key': active_secret, 'X-Workspace-Id': workspace_b},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data'] == {
|
||||
'workspace_uuid': workspace_a,
|
||||
'values': [workspace_a],
|
||||
}
|
||||
|
||||
# The parallel MCP ASGI entrypoint authenticates the same key and
|
||||
# retains only a trusted Workspace scope for the full tool request.
|
||||
# Each DB call gets its own short RLS transaction.
|
||||
mcp_observation: dict[str, typing.Any] = {}
|
||||
|
||||
async def fake_mcp_asgi(scope, receive, send):
|
||||
del scope, receive
|
||||
context = get_mcp_request_context()
|
||||
assert cloud_manager.current_session() is None
|
||||
values = (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
|
||||
assert cloud_manager.current_session() is None
|
||||
await asyncio.sleep(0)
|
||||
assert cloud_manager.current_session() is None
|
||||
repeated_values = (
|
||||
(await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
|
||||
)
|
||||
assert repeated_values == values
|
||||
assert cloud_manager.current_session() is None
|
||||
mcp_observation.update(
|
||||
workspace_uuid=context.workspace_uuid,
|
||||
values=values,
|
||||
)
|
||||
await send({'type': 'http.response.start', 'status': 200, 'headers': []})
|
||||
await send({'type': 'http.response.body', 'body': b'{}'})
|
||||
|
||||
async def unused_quart_asgi(scope, receive, send): # pragma: no cover - routing assertion
|
||||
del scope, receive, send
|
||||
raise AssertionError('MCP request was routed to Quart')
|
||||
|
||||
mount = MCPMount.__new__(MCPMount)
|
||||
mount.ap = runtime_application
|
||||
mount._mcp_asgi = fake_mcp_asgi
|
||||
sent_messages: list[dict[str, typing.Any]] = []
|
||||
|
||||
async def receive():
|
||||
return {'type': 'http.request', 'body': b'', 'more_body': False}
|
||||
|
||||
async def send(message):
|
||||
sent_messages.append(message)
|
||||
|
||||
await mount.wrap(unused_quart_asgi)(
|
||||
{
|
||||
'type': 'http',
|
||||
'path': '/mcp',
|
||||
'headers': [
|
||||
(b'x-api-key', active_secret.encode()),
|
||||
(b'x-workspace-id', workspace_b.encode()),
|
||||
],
|
||||
},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
assert sent_messages[0]['status'] == 200
|
||||
assert mcp_observation == {'workspace_uuid': workspace_a, 'values': [workspace_a]}
|
||||
|
||||
original_api_key_discovery = cloud_manager.api_key_discovery_uow
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def revoke_after_discovery(key_hash: str):
|
||||
async with original_api_key_discovery(key_hash) as discovery:
|
||||
yield discovery
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
await cloud_manager.execute_async(
|
||||
sa.update(ApiKey).where(ApiKey.key_hash == key_hash).values(status='revoked')
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cloud_manager, 'api_key_discovery_uow', revoke_after_discovery)
|
||||
assert await runtime_application.apikey_service.authenticate_api_key(active_secret) is None
|
||||
monkeypatch.setattr(cloud_manager, 'api_key_discovery_uow', original_api_key_discovery)
|
||||
|
||||
quart_app.config['FORCE_HANDLER_FAILURE'] = True
|
||||
response = await client.get(
|
||||
'/tenant-runtime/account',
|
||||
headers={
|
||||
'Authorization': f'Bearer {account_token}',
|
||||
'X-Workspace-Id': workspace_a,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 500
|
||||
quart_app.config['FORCE_HANDLER_FAILURE'] = False
|
||||
async with cloud_manager.tenant_uow(workspace_a):
|
||||
assert (
|
||||
await cloud_manager.execute_async(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(WorkspaceMetadata)
|
||||
.where(WorkspaceMetadata.key == 'rolled-back-handler')
|
||||
)
|
||||
).scalar_one() == 0
|
||||
|
||||
# Transaction-local settings are gone when pooled connections are reused.
|
||||
async with cloud_manager.get_db_engine().connect() as conn:
|
||||
assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (
|
||||
None,
|
||||
'',
|
||||
)
|
||||
assert await conn.scalar(text('SELECT COUNT(*) FROM workspace_metadata')) == 0
|
||||
|
||||
# Runtime validation rejects both extra permissive policies and a
|
||||
# modified expression even if the expected policy name remains.
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text('CREATE POLICY injected_policy ON bots FOR SELECT USING (true)'))
|
||||
with pytest.raises(RuntimeError, match='policy set does not match'):
|
||||
await cloud_manager._validate_postgres_tenant_schema(validate_runtime_role=True)
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text('DROP POLICY injected_policy ON bots'))
|
||||
await conn.execute(text('DROP POLICY langbot_workspace_isolation ON workspace_metadata'))
|
||||
await conn.execute(
|
||||
text(
|
||||
'CREATE POLICY langbot_workspace_isolation ON workspace_metadata '
|
||||
'FOR ALL TO PUBLIC USING (true) WITH CHECK (true)'
|
||||
)
|
||||
)
|
||||
with pytest.raises(RuntimeError, match='policy definitions are invalid'):
|
||||
await cloud_manager._validate_postgres_tenant_schema(validate_runtime_role=True)
|
||||
finally:
|
||||
for manager in reversed(managers):
|
||||
await _dispose_manager(manager)
|
||||
if role_created:
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(role_name)}'))
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
"""Real PostgreSQL/pgvector tenant isolation and CRUD verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.persistence.alembic_runner import get_alembic_current, run_alembic_stamp, run_alembic_upgrade
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase, PgVectorScope
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
|
||||
|
||||
|
||||
def _application(postgres_url: str, logger_name: str) -> SimpleNamespace:
|
||||
url = sa.engine.make_url(postgres_url)
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'postgresql',
|
||||
'postgresql': {
|
||||
'host': url.host,
|
||||
'port': url.port,
|
||||
'user': url.username,
|
||||
'password': url.password,
|
||||
'database': url.database,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger(logger_name),
|
||||
)
|
||||
|
||||
|
||||
def _restore_postgres_registry(monkeypatch) -> None:
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr_module
|
||||
from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
|
||||
|
||||
monkeypatch.setattr(
|
||||
persistence_mgr_module.database,
|
||||
'preregistered_managers',
|
||||
[PostgreSQLDatabaseManager],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_url() -> str:
|
||||
url = os.environ.get('TEST_POSTGRES_URL')
|
||||
if not url:
|
||||
pytest.skip('TEST_POSTGRES_URL not set')
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def postgres_engine(postgres_url: str):
|
||||
engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_database(postgres_engine: AsyncEngine):
|
||||
async def clean() -> None:
|
||||
async with postgres_engine.begin() as conn:
|
||||
await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors_legacy_0013 CASCADE'))
|
||||
await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors CASCADE'))
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
|
||||
|
||||
await clean()
|
||||
yield
|
||||
await clean()
|
||||
|
||||
|
||||
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
) -> None:
|
||||
workspace_uuid = '30000000-0000-0000-0000-000000000303'
|
||||
knowledge_base_uuid = 'legacy-knowledge-base'
|
||||
migrator_role = f'lb_vector_migrator_{uuid.uuid4().hex[:12]}'
|
||||
migrator_password = f'Lb{uuid.uuid4().hex}'
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
database_name = sa.engine.make_url(postgres_url).database
|
||||
migrator_url = (
|
||||
sa.engine.make_url(postgres_url)
|
||||
.set(username=migrator_role, password=migrator_password)
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
migrator_engine: AsyncEngine | None = None
|
||||
role_created = False
|
||||
admin_role: str | None = None
|
||||
source_tables = ('knowledge_bases', 'knowledge_base_files', 'knowledge_base_chunks')
|
||||
|
||||
async def read_rls_states(conn, table_names: tuple[str, ...]) -> dict[str, tuple[bool, bool]]:
|
||||
rows = (
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
|
||||
FROM pg_class AS c
|
||||
JOIN pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relname IN :table_names
|
||||
"""
|
||||
).bindparams(sa.bindparam('table_names', expanding=True)),
|
||||
{'table_names': table_names},
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return {str(row['relname']): (bool(row['relrowsecurity']), bool(row['relforcerowsecurity'])) for row in rows}
|
||||
|
||||
try:
|
||||
async with postgres_engine.begin() as conn:
|
||||
admin_role = await conn.scalar(text('SELECT current_user'))
|
||||
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.execute(
|
||||
text(
|
||||
'ALTER TABLE knowledge_bases DROP CONSTRAINT IF EXISTS '
|
||||
'ck_knowledge_bases_embedding_dimension_positive'
|
||||
)
|
||||
)
|
||||
await conn.execute(text('ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS embedding_dimension'))
|
||||
|
||||
await run_alembic_stamp(postgres_engine, '0010_scope_resources')
|
||||
await run_alembic_upgrade(postgres_engine, '0012_plugin_identity')
|
||||
assert await get_alembic_current(postgres_engine) == '0012_plugin_identity'
|
||||
|
||||
embedding_a = '[' + ','.join(['0.125'] * 384) + ']'
|
||||
embedding_b = '[' + ','.join(['0.25'] * 384) + ']'
|
||||
async with postgres_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO workspaces
|
||||
(uuid, instance_uuid, name, slug, type, status, source, projection_revision)
|
||||
VALUES
|
||||
(:uuid, 'legacy-vector-instance', 'legacy', 'legacy-vector',
|
||||
'team', 'active', 'cloud_projection', 0)
|
||||
"""
|
||||
),
|
||||
{'uuid': workspace_uuid},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO knowledge_bases
|
||||
(uuid, workspace_uuid, name, collection_id, legacy_vector_collection)
|
||||
VALUES
|
||||
(:uuid, :workspace_uuid, 'legacy', 'legacy-collection', true)
|
||||
"""
|
||||
),
|
||||
{'uuid': knowledge_base_uuid, 'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO knowledge_base_files
|
||||
(uuid, workspace_uuid, kb_id, file_name, extension, status)
|
||||
VALUES
|
||||
('legacy-file', :workspace_uuid, :kb_uuid, 'legacy.txt', 'txt', 'completed')
|
||||
"""
|
||||
),
|
||||
{'workspace_uuid': workspace_uuid, 'kb_uuid': knowledge_base_uuid},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO knowledge_base_chunks (uuid, workspace_uuid, file_id, text)
|
||||
VALUES ('legacy-chunk', :workspace_uuid, 'legacy-file', 'chunk text')
|
||||
"""
|
||||
),
|
||||
{'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE langbot_vectors (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
collection VARCHAR(255),
|
||||
embedding vector NOT NULL,
|
||||
text TEXT,
|
||||
file_id VARCHAR(255),
|
||||
chunk_uuid VARCHAR(255)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO langbot_vectors
|
||||
(id, collection, embedding, text, file_id, chunk_uuid)
|
||||
VALUES
|
||||
('legacy-by-collection', 'legacy-collection', CAST(:embedding_a AS vector),
|
||||
'collection row', NULL, NULL),
|
||||
('legacy-by-chunk', 'unmatched-collection', CAST(:embedding_b AS vector),
|
||||
'chunk row', NULL, 'legacy-chunk')
|
||||
"""
|
||||
),
|
||||
{'embedding_a': embedding_a, 'embedding_b': embedding_b},
|
||||
)
|
||||
|
||||
# Exercise exact restoration rather than assuming all source tables
|
||||
# arrived with identical flags.
|
||||
await conn.execute(text('ALTER TABLE knowledge_base_files NO FORCE ROW LEVEL SECURITY'))
|
||||
await conn.execute(text('ALTER TABLE knowledge_base_chunks NO FORCE ROW LEVEL SECURITY'))
|
||||
await conn.execute(text('ALTER TABLE knowledge_base_chunks DISABLE ROW LEVEL SECURITY'))
|
||||
expected_source_rls = await read_rls_states(conn, source_tables)
|
||||
assert expected_source_rls == {
|
||||
'knowledge_bases': (True, True),
|
||||
'knowledge_base_files': (True, False),
|
||||
'knowledge_base_chunks': (False, False),
|
||||
}
|
||||
|
||||
await conn.execute(
|
||||
text(f"CREATE ROLE {quote(migrator_role)} LOGIN PASSWORD '{migrator_password}' NOSUPERUSER NOBYPASSRLS")
|
||||
)
|
||||
role_created = True
|
||||
await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(migrator_role)}'))
|
||||
await conn.execute(text(f'GRANT USAGE, CREATE ON SCHEMA public TO {quote(migrator_role)}'))
|
||||
await conn.execute(text(f'GRANT SELECT, UPDATE ON alembic_version TO {quote(migrator_role)}'))
|
||||
for table_name in (*source_tables, 'langbot_vectors'):
|
||||
await conn.execute(text(f'ALTER TABLE {quote(table_name)} OWNER TO {quote(migrator_role)}'))
|
||||
|
||||
role = (
|
||||
await conn.execute(
|
||||
text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = :role'),
|
||||
{'role': migrator_role},
|
||||
)
|
||||
).one()
|
||||
assert role == (False, False)
|
||||
owned_tables = set(
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.relname
|
||||
FROM pg_class AS c
|
||||
JOIN pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relname IN :table_names
|
||||
AND pg_get_userbyid(c.relowner) = :role
|
||||
"""
|
||||
).bindparams(sa.bindparam('table_names', expanding=True)),
|
||||
{'table_names': (*source_tables, 'langbot_vectors'), 'role': migrator_role},
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert owned_tables == {*source_tables, 'langbot_vectors'}
|
||||
|
||||
migrator_engine = create_async_engine(migrator_url)
|
||||
await run_alembic_upgrade(migrator_engine, '0013_tenant_pgvector')
|
||||
assert await get_alembic_current(migrator_engine) == '0013_tenant_pgvector'
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
migrated_rows = (
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT workspace_uuid, knowledge_base_uuid, vector_id,
|
||||
embedding_dimension, text, file_id, chunk_uuid
|
||||
FROM langbot_vectors
|
||||
ORDER BY vector_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
assert [dict(row) for row in migrated_rows] == [
|
||||
{
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'knowledge_base_uuid': knowledge_base_uuid,
|
||||
'vector_id': 'legacy-by-chunk',
|
||||
'embedding_dimension': 384,
|
||||
'text': 'chunk row',
|
||||
'file_id': None,
|
||||
'chunk_uuid': 'legacy-chunk',
|
||||
},
|
||||
{
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'knowledge_base_uuid': knowledge_base_uuid,
|
||||
'vector_id': 'legacy-by-collection',
|
||||
'embedding_dimension': 384,
|
||||
'text': 'collection row',
|
||||
'file_id': None,
|
||||
'chunk_uuid': None,
|
||||
},
|
||||
]
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text('SELECT embedding_dimension FROM knowledge_bases WHERE uuid = :uuid'),
|
||||
{'uuid': knowledge_base_uuid},
|
||||
)
|
||||
== 384
|
||||
)
|
||||
assert await conn.scalar(text("SELECT to_regclass('langbot_vectors_legacy_0013') IS NULL")) is True
|
||||
assert await read_rls_states(conn, source_tables) == expected_source_rls
|
||||
assert await read_rls_states(conn, ('langbot_vectors',)) == {'langbot_vectors': (True, True)}
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM pg_policy AS p
|
||||
JOIN pg_class AS c ON c.oid = p.polrelid
|
||||
JOIN pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relname = 'langbot_vectors'
|
||||
AND p.polname = 'langbot_workspace_isolation'
|
||||
"""
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
async with migrator_engine.connect() as conn:
|
||||
assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0
|
||||
async with migrator_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"),
|
||||
{'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 2
|
||||
finally:
|
||||
if migrator_engine is not None:
|
||||
await migrator_engine.dispose()
|
||||
if role_created:
|
||||
async with postgres_engine.connect() as conn:
|
||||
if admin_role is None: # pragma: no cover - setup cannot create the role without an admin
|
||||
admin_role = await conn.scalar(text('SELECT current_user'))
|
||||
await conn.execute(text(f'REASSIGN OWNED BY {quote(migrator_role)} TO {quote(admin_role)}'))
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(migrator_role)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(migrator_role)}'))
|
||||
|
||||
|
||||
async def test_pgvector_shared_database_is_scoped_indexed_and_ddl_free_at_runtime(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
instance_uuid = 'pgvector-tenant-integration'
|
||||
workspace_a = '10000000-0000-0000-0000-000000000101'
|
||||
workspace_b = '20000000-0000-0000-0000-000000000202'
|
||||
kb_a = 'knowledge-base-a'
|
||||
kb_b = 'knowledge-base-b'
|
||||
role_suffix = uuid.uuid4().hex[:12]
|
||||
runtime_role = f'lb_vector_runtime_{role_suffix}'
|
||||
role_password = f'Lb{uuid.uuid4().hex}'
|
||||
release_manager: PersistenceManager | None = None
|
||||
runtime_manager: PersistenceManager | None = None
|
||||
role_created = False
|
||||
|
||||
_restore_postgres_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', instance_uuid)
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
database_name = sa.engine.make_url(postgres_url).database
|
||||
runtime_url = (
|
||||
sa.engine.make_url(postgres_url)
|
||||
.set(username=runtime_role, password=role_password)
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
|
||||
try:
|
||||
release_app = _application(postgres_url, 'pgvector-release-migration-test')
|
||||
release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION)
|
||||
release_app.persistence_mgr = release_manager
|
||||
await release_manager.initialize()
|
||||
|
||||
for workspace_uuid, kb_uuid, slug in (
|
||||
(workspace_a, kb_a, 'vector-a'),
|
||||
(workspace_b, kb_b, 'vector-b'),
|
||||
):
|
||||
async with release_manager.tenant_uow(workspace_uuid) as uow:
|
||||
await uow.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO workspaces
|
||||
(uuid, instance_uuid, name, slug, type, status, source, projection_revision)
|
||||
VALUES
|
||||
(:uuid, :instance_uuid, :name, :slug, 'team', 'active', 'cloud_projection', 0)
|
||||
"""
|
||||
),
|
||||
{
|
||||
'uuid': workspace_uuid,
|
||||
'instance_uuid': instance_uuid,
|
||||
'name': slug,
|
||||
'slug': slug,
|
||||
},
|
||||
)
|
||||
await uow.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO knowledge_bases
|
||||
(uuid, workspace_uuid, name, embedding_dimension)
|
||||
VALUES (:uuid, :workspace_uuid, :name, 384)
|
||||
"""
|
||||
),
|
||||
{'uuid': kb_uuid, 'workspace_uuid': workspace_uuid, 'name': slug},
|
||||
)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{role_password}'"))
|
||||
await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}'))
|
||||
business_tables = release_manager._runtime_business_table_names()
|
||||
quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables)
|
||||
await conn.execute(
|
||||
text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}')
|
||||
)
|
||||
await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(runtime_role)}'))
|
||||
sequence_names = await release_manager._runtime_business_sequence_names(conn, business_tables)
|
||||
if sequence_names:
|
||||
quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names)
|
||||
await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(runtime_role)}'))
|
||||
role_created = True
|
||||
|
||||
runtime_app = _application(runtime_url, 'pgvector-runtime-test')
|
||||
runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
runtime_app.persistence_mgr = runtime_manager
|
||||
await runtime_manager.initialize()
|
||||
|
||||
adapter = PgVectorDatabase(
|
||||
runtime_app,
|
||||
use_business_database=True,
|
||||
allowed_dimensions=[384],
|
||||
)
|
||||
scope_a = PgVectorScope(workspace_a, kb_a, 384)
|
||||
scope_b = PgVectorScope(workspace_b, kb_b, 384)
|
||||
|
||||
# The same vector ID is valid in two Workspaces because the relational
|
||||
# primary key includes Workspace and knowledge base.
|
||||
await adapter.add_embeddings(
|
||||
'opaque-a',
|
||||
['same-vector'],
|
||||
[[0.1] * 384],
|
||||
[{'text': 'workspace-a', 'file_id': 'file-a', 'uuid': 'chunk-a'}],
|
||||
scope=scope_a,
|
||||
)
|
||||
await adapter.add_embeddings(
|
||||
'opaque-b',
|
||||
['same-vector'],
|
||||
[[0.2] * 384],
|
||||
[{'text': 'workspace-b', 'file_id': 'file-b', 'uuid': 'chunk-b'}],
|
||||
scope=scope_b,
|
||||
)
|
||||
|
||||
result_a = await adapter.search('opaque-a', [0.1] * 384, scope=scope_a)
|
||||
result_b = await adapter.search('opaque-b', [0.2] * 384, scope=scope_b)
|
||||
assert result_a['metadatas'][0][0]['text'] == 'workspace-a'
|
||||
assert result_b['metadatas'][0][0]['text'] == 'workspace-b'
|
||||
|
||||
# Guessing another knowledge-base UUID while retaining A's Workspace
|
||||
# cannot escape either the explicit conditions or PostgreSQL RLS.
|
||||
guessed = await adapter.search(
|
||||
'attacker-controlled-name',
|
||||
[0.2] * 384,
|
||||
scope=PgVectorScope(workspace_a, kb_b, 384),
|
||||
)
|
||||
assert guessed['ids'] == [[]]
|
||||
|
||||
with pytest.raises(ValueError, match='trusted PgVectorScope'):
|
||||
await adapter.search('opaque-a', [0.1] * 384)
|
||||
with pytest.raises(ValueError, match='selected dimension'):
|
||||
await adapter.add_embeddings(
|
||||
'opaque-a',
|
||||
['bad-dimension'],
|
||||
[[0.1] * 383],
|
||||
[{}],
|
||||
scope=scope_a,
|
||||
)
|
||||
|
||||
# Deliberately omit the application Workspace predicate. FORCE RLS is
|
||||
# still the second isolation boundary and returns only A.
|
||||
async with runtime_manager.tenant_uow(workspace_a) as uow:
|
||||
rows = (
|
||||
await uow.execute(
|
||||
text("SELECT workspace_uuid, vector_id FROM langbot_vectors WHERE vector_id = 'same-vector'")
|
||||
)
|
||||
).all()
|
||||
assert rows == [(workspace_a, 'same-vector')]
|
||||
await uow.execute(text('SET LOCAL enable_seqscan = off'))
|
||||
plan = '\n'.join(
|
||||
(
|
||||
await uow.execute(
|
||||
text(
|
||||
"""
|
||||
EXPLAIN SELECT vector_id
|
||||
FROM langbot_vectors
|
||||
WHERE workspace_uuid = :workspace_uuid
|
||||
AND knowledge_base_uuid = :knowledge_base_uuid
|
||||
AND embedding_dimension = 384
|
||||
ORDER BY (embedding::vector(384)) <=> CAST(:query AS vector(384))
|
||||
LIMIT 5
|
||||
"""
|
||||
),
|
||||
{
|
||||
'workspace_uuid': workspace_a,
|
||||
'knowledge_base_uuid': kb_a,
|
||||
'query': '[' + ','.join(['0.1'] * 384) + ']',
|
||||
},
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert 'ix_langbot_vectors_hnsw_cosine_384' in plan
|
||||
|
||||
runtime_engine = runtime_manager.get_db_engine()
|
||||
async with runtime_engine.connect() as conn:
|
||||
assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0
|
||||
assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (None, '')
|
||||
|
||||
items_a, total_a = await adapter.list_by_filter('opaque-a', scope=scope_a)
|
||||
assert total_a == 1
|
||||
assert items_a[0]['metadata']['file_id'] == 'file-a'
|
||||
await adapter.delete_by_file_id('opaque-a', 'file-a', scope=scope_a)
|
||||
assert (await adapter.list_by_filter('opaque-a', scope=scope_a))[1] == 0
|
||||
assert (await adapter.list_by_filter('opaque-b', scope=scope_b))[1] == 1
|
||||
finally:
|
||||
if runtime_manager is not None and getattr(runtime_manager, 'db', None) is not None:
|
||||
await runtime_manager.get_db_engine().dispose()
|
||||
if release_manager is not None and getattr(release_manager, 'db', None) is not None:
|
||||
await release_manager.get_db_engine().dispose()
|
||||
if role_created:
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
get_alembic_current,
|
||||
run_alembic_stamp,
|
||||
run_alembic_upgrade,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
async def test_legacy_plugin_settings_receive_stable_random_installation_identities(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "plugin-identity.db"}')
|
||||
metadata = sa.MetaData()
|
||||
plugin_settings = sa.Table(
|
||||
'plugin_settings',
|
||||
metadata,
|
||||
sa.Column('workspace_uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('plugin_author', sa.String(255), primary_key=True),
|
||||
sa.Column('plugin_name', sa.String(255), primary_key=True),
|
||||
sa.Column('enabled', sa.Boolean, nullable=False, server_default=sa.true()),
|
||||
sa.Column('priority', sa.Integer, nullable=False, server_default='0'),
|
||||
sa.Column('config', sa.JSON, nullable=False, server_default='{}'),
|
||||
sa.Column('install_source', sa.String(255), nullable=False, server_default='local'),
|
||||
sa.Column('install_info', sa.JSON, nullable=False, server_default='{}'),
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(metadata.create_all)
|
||||
await connection.execute(
|
||||
plugin_settings.insert(),
|
||||
[
|
||||
{
|
||||
'workspace_uuid': '11111111-1111-4111-8111-111111111111',
|
||||
'plugin_author': 'author',
|
||||
'plugin_name': 'one',
|
||||
},
|
||||
{
|
||||
'workspace_uuid': '22222222-2222-4222-8222-222222222222',
|
||||
'plugin_author': 'author',
|
||||
'plugin_name': 'two',
|
||||
},
|
||||
],
|
||||
)
|
||||
await run_alembic_stamp(engine, '0011_postgres_tenant_rls')
|
||||
await run_alembic_upgrade(engine, '0012_plugin_identity')
|
||||
|
||||
async with engine.connect() as connection:
|
||||
rows = (
|
||||
(
|
||||
await connection.execute(
|
||||
sa.text(
|
||||
'SELECT installation_uuid, artifact_digest, runtime_revision '
|
||||
'FROM plugin_settings ORDER BY workspace_uuid'
|
||||
)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
columns = await connection.run_sync(
|
||||
lambda sync_connection: {
|
||||
column['name']: column for column in sa.inspect(sync_connection).get_columns('plugin_settings')
|
||||
}
|
||||
)
|
||||
indexes = await connection.run_sync(
|
||||
lambda sync_connection: {
|
||||
index['name']: index for index in sa.inspect(sync_connection).get_indexes('plugin_settings')
|
||||
}
|
||||
)
|
||||
|
||||
assert await get_alembic_current(engine) == '0012_plugin_identity'
|
||||
assert columns['installation_uuid']['nullable'] is False
|
||||
assert columns['artifact_digest']['nullable'] is False
|
||||
assert columns['runtime_revision']['nullable'] is False
|
||||
assert indexes['ix_plugin_settings_workspace_installation']['unique'] == 1
|
||||
assert len({row['installation_uuid'] for row in rows}) == 2
|
||||
for row in rows:
|
||||
uuid.UUID(row['installation_uuid'])
|
||||
assert row['runtime_revision'] == 1
|
||||
assert (
|
||||
row['artifact_digest']
|
||||
== hashlib.sha256(f'legacy-installation:{row["installation_uuid"]}'.encode()).hexdigest()
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,723 @@
|
||||
"""Real PostgreSQL coverage for the one-shot Cloud release migration job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from langbot.pkg.persistence import release_migration
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
get_alembic_current,
|
||||
get_alembic_head,
|
||||
run_alembic_stamp,
|
||||
run_alembic_upgrade,
|
||||
)
|
||||
from langbot.pkg.persistence.mgr import (
|
||||
PersistenceManager,
|
||||
PersistenceMode,
|
||||
_RELEASE_MIGRATION_ADVISORY_LOCK_ID,
|
||||
)
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
|
||||
_RUNTIME_PASSWORD = 'runtime-secret-not-used-by-migration'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_url() -> str:
|
||||
url = os.environ.get('TEST_POSTGRES_URL')
|
||||
if not url:
|
||||
pytest.skip('TEST_POSTGRES_URL not set')
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def postgres_engine(postgres_url: str):
|
||||
engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_database(postgres_engine: AsyncEngine):
|
||||
async def clean() -> None:
|
||||
async with postgres_engine.begin() as conn:
|
||||
table_names = await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
for table_name in table_names:
|
||||
await conn.execute(text(f'DROP TABLE {quote(table_name)} CASCADE'))
|
||||
|
||||
await clean()
|
||||
yield
|
||||
await clean()
|
||||
|
||||
|
||||
def _restore_postgres_registry(monkeypatch) -> None:
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr_module
|
||||
from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
|
||||
|
||||
monkeypatch.setattr(
|
||||
persistence_mgr_module.database,
|
||||
'preregistered_managers',
|
||||
[PostgreSQLDatabaseManager],
|
||||
)
|
||||
|
||||
|
||||
def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_used_by_migration') -> SimpleNamespace:
|
||||
url = sa.engine.make_url(postgres_url)
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'postgresql',
|
||||
'postgresql': {
|
||||
'host': url.host,
|
||||
'port': url.port,
|
||||
# This is deliberately not the operator role in the DSN.
|
||||
'user': runtime_role,
|
||||
'password': _RUNTIME_PASSWORD,
|
||||
'database': url.database,
|
||||
},
|
||||
'cloud_migration': {'operator_dsn_env': 'TEST_RELEASE_OPERATOR_DSN'},
|
||||
},
|
||||
'vdb': {
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [384, 512, 768, 1024, 1536],
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger('cloud-release-migration-entrypoint-test'),
|
||||
persistence_mgr=None,
|
||||
)
|
||||
|
||||
|
||||
async def test_release_entrypoint_holds_lock_migrates_validates_and_disposes(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_restore_postgres_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', 'release-migration-entrypoint-test')
|
||||
original_validate = PersistenceManager._validate_release_schema
|
||||
validation_observed_lock = False
|
||||
runtime_role = f'lb_release_runtime_{uuid.uuid4().hex[:12]}'
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
|
||||
async def validate_while_asserting_lock(self: PersistenceManager) -> None:
|
||||
nonlocal validation_observed_lock
|
||||
async with postgres_engine.connect() as conn:
|
||||
acquired = await conn.scalar(
|
||||
text('SELECT pg_try_advisory_lock(:lock_id)'),
|
||||
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
if acquired:
|
||||
await conn.scalar(
|
||||
text('SELECT pg_advisory_unlock(:lock_id)'),
|
||||
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
assert acquired is False
|
||||
validation_observed_lock = True
|
||||
await original_validate(self)
|
||||
|
||||
monkeypatch.setattr(PersistenceManager, '_validate_release_schema', validate_while_asserting_lock)
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{_RUNTIME_PASSWORD}'"))
|
||||
# The release job must make this otherwise bare LOGIN usable without
|
||||
# relying on pre-provisioned object ACLs.
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text(
|
||||
"""
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
CROSS JOIN LATERAL aclexplode(c.relacl) acl
|
||||
JOIN pg_roles grantee ON grantee.oid = acl.grantee
|
||||
WHERE n.nspname = current_schema()
|
||||
AND grantee.rolname = :runtime_role
|
||||
)
|
||||
"""
|
||||
),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
is True
|
||||
)
|
||||
ap = _application(postgres_url, runtime_role=runtime_role)
|
||||
try:
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
|
||||
assert validation_observed_lock is True
|
||||
assert await get_alembic_current(postgres_engine) == get_alembic_head()
|
||||
manager = ap.persistence_mgr
|
||||
assert isinstance(manager, PersistenceManager)
|
||||
business_tables = set(manager._runtime_business_table_names())
|
||||
async with postgres_engine.connect() as conn:
|
||||
assert await conn.scalar(text("SELECT to_regclass('langbot_vectors') IS NOT NULL")) is True
|
||||
assert (
|
||||
await conn.scalar(text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')")) is True
|
||||
)
|
||||
runtime_role_state = (
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
rolcanlogin,
|
||||
rolsuper,
|
||||
rolbypassrls,
|
||||
rolcreatedb,
|
||||
rolcreaterole,
|
||||
rolreplication
|
||||
FROM pg_roles
|
||||
WHERE rolname = :runtime_role
|
||||
"""
|
||||
),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
assert dict(runtime_role_state) == {
|
||||
'rolcanlogin': True,
|
||||
'rolsuper': False,
|
||||
'rolbypassrls': False,
|
||||
'rolcreatedb': False,
|
||||
'rolcreaterole': False,
|
||||
'rolreplication': False,
|
||||
}
|
||||
|
||||
database_privileges = set(
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT acl.privilege_type
|
||||
FROM pg_database database
|
||||
CROSS JOIN LATERAL aclexplode(database.datacl) acl
|
||||
JOIN pg_roles grantee ON grantee.oid = acl.grantee
|
||||
WHERE database.datname = current_database()
|
||||
AND grantee.rolname = :runtime_role
|
||||
AND acl.is_grantable IS FALSE
|
||||
"""
|
||||
),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
schema_privileges = set(
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT acl.privilege_type
|
||||
FROM pg_namespace namespace
|
||||
CROSS JOIN LATERAL aclexplode(namespace.nspacl) acl
|
||||
JOIN pg_roles grantee ON grantee.oid = acl.grantee
|
||||
WHERE namespace.nspname = current_schema()
|
||||
AND grantee.rolname = :runtime_role
|
||||
AND acl.is_grantable IS FALSE
|
||||
"""
|
||||
),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert database_privileges == {'CONNECT'}
|
||||
assert schema_privileges == {'USAGE'}
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text("SELECT has_database_privilege(:runtime_role, current_database(), 'CREATE')"),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text("SELECT has_schema_privilege(:runtime_role, current_schema(), 'CREATE')"),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
is False
|
||||
)
|
||||
# PostgreSQL grants TEMP to PUBLIC by default. The first Cloud
|
||||
# release deliberately tolerates that inherited compatibility
|
||||
# privilege while granting no direct TEMP ACL to the runtime role.
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text("SELECT has_database_privilege(:runtime_role, current_database(), 'TEMP')"),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
object_grants = (
|
||||
(
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
c.relname,
|
||||
c.relkind::text AS relkind,
|
||||
acl.privilege_type,
|
||||
acl.is_grantable
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
CROSS JOIN LATERAL aclexplode(c.relacl) acl
|
||||
JOIN pg_roles grantee ON grantee.oid = acl.grantee
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p', 'S')
|
||||
AND grantee.rolname = :runtime_role
|
||||
ORDER BY c.relname, acl.privilege_type
|
||||
"""
|
||||
),
|
||||
{'runtime_role': runtime_role},
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
direct_table_grants: dict[str, set[str]] = {}
|
||||
direct_sequence_grants: dict[str, set[str]] = {}
|
||||
for grant in object_grants:
|
||||
assert grant['is_grantable'] is False
|
||||
target = direct_sequence_grants if grant['relkind'] == 'S' else direct_table_grants
|
||||
target.setdefault(grant['relname'], set()).add(grant['privilege_type'])
|
||||
|
||||
assert set(direct_table_grants) == business_tables | {'alembic_version'}
|
||||
assert all(
|
||||
privileges == {'SELECT', 'INSERT', 'UPDATE', 'DELETE'}
|
||||
for table_name, privileges in direct_table_grants.items()
|
||||
if table_name != 'alembic_version'
|
||||
)
|
||||
assert direct_table_grants['alembic_version'] == {'SELECT'}
|
||||
assert direct_sequence_grants
|
||||
assert all(privileges == {'USAGE', 'SELECT'} for privileges in direct_sequence_grants.values())
|
||||
# The session-level lock must be released before the one-shot job exits.
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text('SELECT pg_try_advisory_lock(:lock_id)'),
|
||||
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
await conn.scalar(
|
||||
text('SELECT pg_advisory_unlock(:lock_id)'),
|
||||
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
runtime_url = (
|
||||
sa.engine.make_url(postgres_url)
|
||||
.set(username=runtime_role, password=_RUNTIME_PASSWORD)
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
runtime_engine = create_async_engine(runtime_url)
|
||||
try:
|
||||
async with runtime_engine.begin() as conn:
|
||||
assert await conn.scalar(text('SELECT current_user')) == runtime_role
|
||||
await conn.execute(text("INSERT INTO metadata (key, value) VALUES ('runtime-grant-smoke', 'created')"))
|
||||
await conn.execute(text("UPDATE metadata SET value = 'updated' WHERE key = 'runtime-grant-smoke'"))
|
||||
assert (
|
||||
await conn.scalar(text("SELECT value FROM metadata WHERE key = 'runtime-grant-smoke'")) == 'updated'
|
||||
)
|
||||
await conn.execute(text("DELETE FROM metadata WHERE key = 'runtime-grant-smoke'"))
|
||||
await conn.scalar(
|
||||
text('SELECT nextval(CAST(:sequence_name AS regclass))'),
|
||||
{'sequence_name': f'public.{sorted(direct_sequence_grants)[0]}'},
|
||||
)
|
||||
finally:
|
||||
await runtime_engine.dispose()
|
||||
|
||||
runtime_application = _application(postgres_url, runtime_role=runtime_role)
|
||||
runtime_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
runtime_application.persistence_mgr = runtime_manager
|
||||
try:
|
||||
# This reads alembic_version as the actual runtime role and then
|
||||
# reruns the complete grant/catalog validator before startup.
|
||||
await runtime_manager.initialize()
|
||||
finally:
|
||||
await runtime_manager.get_db_engine().dispose()
|
||||
|
||||
# The catalog validator must fail closed if the role is no longer
|
||||
# deployable, even though the remaining grants still look plausible.
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'REVOKE DELETE ON TABLE public.metadata FROM {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match="table 'metadata' grants are incomplete"):
|
||||
await manager._validate_configured_runtime_postgres_role(require_grants=True)
|
||||
finally:
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
|
||||
|
||||
|
||||
async def test_release_entrypoint_rejects_privileged_or_table_owning_runtime_role(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_restore_postgres_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', 'release-runtime-role-validation-test')
|
||||
runtime_role = f'lb_release_runtime_{uuid.uuid4().hex[:12]}'
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
async with postgres_engine.connect() as conn:
|
||||
operator_role = await conn.scalar(text('SELECT current_user'))
|
||||
await conn.execute(text(f'CREATE ROLE {quote(runtime_role)} LOGIN'))
|
||||
|
||||
ap = _application(postgres_url, runtime_role=runtime_role)
|
||||
try:
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SUPERUSER'))
|
||||
with pytest.raises(RuntimeError, match='must not be superuser or BYPASSRLS'):
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} NOSUPERUSER BYPASSRLS'))
|
||||
with pytest.raises(RuntimeError, match='must not be superuser or BYPASSRLS'):
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} NOBYPASSRLS'))
|
||||
await conn.execute(text(f'ALTER TABLE bots OWNER TO {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='owns tenant tables'):
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
finally:
|
||||
async with postgres_engine.connect() as conn:
|
||||
table_exists = await conn.scalar(text("SELECT to_regclass('bots') IS NOT NULL"))
|
||||
if table_exists:
|
||||
await conn.execute(text(f'ALTER TABLE bots OWNER TO {quote(operator_role)}'))
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
|
||||
|
||||
|
||||
async def test_runtime_role_catalog_validator_rejects_delegation_and_escape_hatches(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_restore_postgres_registry(monkeypatch)
|
||||
monkeypatch.setattr(constants, 'instance_id', 'release-runtime-role-catalog-test')
|
||||
suffix = uuid.uuid4().hex[:12]
|
||||
runtime_role = f'lb_release_runtime_{suffix}'
|
||||
delegated_role = f'lb_release_delegate_{suffix}'
|
||||
extra_schema = f'lb_release_schema_{suffix}'
|
||||
extra_view = f'lb_release_view_{suffix}'
|
||||
owned_routine = f'lb_release_owned_routine_{suffix}'
|
||||
security_definer = f'lb_release_definer_{suffix}'
|
||||
foreign_wrapper = f'lb_release_fdw_{suffix}'
|
||||
foreign_server = f'lb_release_server_{suffix}'
|
||||
persistent_setting_secret = f'lb_release_secret_{suffix}'
|
||||
database_name = sa.engine.make_url(postgres_url).database
|
||||
assert database_name
|
||||
quote = postgres_engine.dialect.identifier_preparer.quote
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{_RUNTIME_PASSWORD}'"))
|
||||
await conn.execute(text(f'CREATE ROLE {quote(delegated_role)}'))
|
||||
|
||||
ap = _application(postgres_url, runtime_role=runtime_role)
|
||||
try:
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
manager = ap.persistence_mgr
|
||||
assert isinstance(manager, PersistenceManager)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'GRANT pg_read_all_data TO {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='must not participate in role memberships'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'REVOKE pg_read_all_data FROM {quote(runtime_role)}'))
|
||||
|
||||
# Reject delegation in the other direction too: no role may be
|
||||
# allowed to SET ROLE to the runtime identity or administer it.
|
||||
await conn.execute(text(f'GRANT {quote(runtime_role)} TO {quote(delegated_role)} WITH ADMIN OPTION'))
|
||||
with pytest.raises(RuntimeError, match='must not participate in role memberships'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'REVOKE {quote(runtime_role)} FROM {quote(delegated_role)}'))
|
||||
|
||||
await conn.execute(
|
||||
text(f'GRANT SELECT ON TABLE public.metadata TO {quote(runtime_role)} WITH GRANT OPTION')
|
||||
)
|
||||
with pytest.raises(RuntimeError, match='GRANT OPTION'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(f'REVOKE GRANT OPTION FOR SELECT ON TABLE public.metadata FROM {quote(runtime_role)}')
|
||||
)
|
||||
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SET search_path TO public'))
|
||||
with pytest.raises(RuntimeError, match='persistent session overrides'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET search_path'))
|
||||
|
||||
await conn.execute(text(f'ALTER DATABASE {quote(database_name)} SET search_path TO public'))
|
||||
with pytest.raises(RuntimeError, match='persistent session overrides'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER DATABASE {quote(database_name)} RESET search_path'))
|
||||
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SET session_replication_role TO replica'))
|
||||
with pytest.raises(RuntimeError, match='persistent session overrides'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET session_replication_role'))
|
||||
|
||||
await conn.execute(
|
||||
text(f"ALTER ROLE {quote(runtime_role)} SET application_name TO '{persistent_setting_secret}'")
|
||||
)
|
||||
with pytest.raises(RuntimeError, match='persistent session overrides') as error:
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
assert persistent_setting_secret not in str(error.value)
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET application_name'))
|
||||
|
||||
await conn.execute(text('CREATE EXTENSION dblink'))
|
||||
with pytest.raises(RuntimeError, match='extensions must include vector and be limited'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text('DROP EXTENSION dblink'))
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'GRANT CREATE ON DATABASE {quote(database_name)} TO {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'GRANT CREATE ON SCHEMA public TO {quote(runtime_role)}'))
|
||||
runtime_url = (
|
||||
sa.engine.make_url(postgres_url)
|
||||
.set(username=runtime_role, password=_RUNTIME_PASSWORD)
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
runtime_engine = create_async_engine(runtime_url)
|
||||
try:
|
||||
async with runtime_engine.begin() as conn:
|
||||
# hstore is a trusted extension in the production PG16 image,
|
||||
# so this creates a real runtime-owned extension catalog row.
|
||||
await conn.execute(text('CREATE EXTENSION hstore'))
|
||||
finally:
|
||||
await runtime_engine.dispose()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'REVOKE CREATE ON SCHEMA public FROM {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'REVOKE CREATE ON DATABASE {quote(database_name)} FROM {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='must not own extensions'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text('DROP EXTENSION hstore CASCADE'))
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'CREATE FOREIGN DATA WRAPPER {quote(foreign_wrapper)}'))
|
||||
await conn.execute(
|
||||
text(f'CREATE SERVER {quote(foreign_server)} FOREIGN DATA WRAPPER {quote(foreign_wrapper)}')
|
||||
)
|
||||
await conn.execute(text(f'CREATE USER MAPPING FOR {quote(runtime_role)} SERVER {quote(foreign_server)}'))
|
||||
with pytest.raises(RuntimeError, match='foreign data wrappers, servers, or user mappings'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP FOREIGN DATA WRAPPER {quote(foreign_wrapper)} CASCADE'))
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'CREATE SCHEMA {quote(extra_schema)} AUTHORIZATION {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='non-business schemas'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP SCHEMA {quote(extra_schema)} CASCADE'))
|
||||
|
||||
await conn.execute(text(f'CREATE VIEW public.{quote(extra_view)} AS SELECT key FROM public.metadata'))
|
||||
await conn.execute(text(f'GRANT SELECT ON public.{quote(extra_view)} TO {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='non-business objects|table privileges are unsafe'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP VIEW public.{quote(extra_view)}'))
|
||||
|
||||
await conn.execute(text(f'GRANT SELECT (key) ON public.metadata TO {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='column-level ACLs'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'REVOKE SELECT (key) ON public.metadata FROM {quote(runtime_role)}'))
|
||||
|
||||
# System file access functions are not SECURITY DEFINER, so the
|
||||
# validator must reject their explicit EXECUTE ACL independently.
|
||||
await conn.execute(
|
||||
text(f'GRANT EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) TO {quote(runtime_role)}')
|
||||
)
|
||||
runtime_application = _application(postgres_url, runtime_role=runtime_role)
|
||||
runtime_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
runtime_application.persistence_mgr = runtime_manager
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match='explicit EXECUTE privileges on routines'):
|
||||
await runtime_manager.initialize()
|
||||
finally:
|
||||
await runtime_manager.get_db_engine().dispose()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(f'REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) FROM {quote(runtime_role)}')
|
||||
)
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
# replica disables ordinary triggers/rules and foreign-key
|
||||
# enforcement; it must never reach the runtime identity.
|
||||
await conn.execute(text(f'GRANT SET ON PARAMETER session_replication_role TO {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='explicit SET or ALTER SYSTEM parameter privileges'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'REVOKE SET ON PARAMETER session_replication_role FROM {quote(runtime_role)}'))
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(f"CREATE FUNCTION public.{quote(owned_routine)}() RETURNS integer LANGUAGE sql AS 'SELECT 1'")
|
||||
)
|
||||
await conn.execute(text(f'ALTER FUNCTION public.{quote(owned_routine)}() OWNER TO {quote(runtime_role)}'))
|
||||
with pytest.raises(RuntimeError, match='must not own routines'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'DROP FUNCTION public.{quote(owned_routine)}()'))
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
f'CREATE FUNCTION public.{quote(security_definer)}() RETURNS integer '
|
||||
"LANGUAGE sql SECURITY DEFINER AS 'SELECT 1'"
|
||||
)
|
||||
)
|
||||
# Extension membership must not exempt an executable definer from
|
||||
# the runtime audit, even for an allowlisted extension.
|
||||
await conn.execute(text(f'ALTER EXTENSION vector ADD FUNCTION public.{quote(security_definer)}()'))
|
||||
with pytest.raises(RuntimeError, match='SECURITY DEFINER'):
|
||||
await manager._validate_configured_runtime_postgres_role()
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER EXTENSION vector DROP FUNCTION public.{quote(security_definer)}()'))
|
||||
finally:
|
||||
async with postgres_engine.connect() as conn:
|
||||
await conn.execute(text(f'ALTER DATABASE {quote(database_name)} RESET search_path'))
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET search_path'))
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET session_replication_role'))
|
||||
await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET application_name'))
|
||||
await conn.execute(text(f'REVOKE pg_read_all_data FROM {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'REVOKE {quote(runtime_role)} FROM {quote(delegated_role)}'))
|
||||
await conn.execute(text(f'REVOKE CREATE ON SCHEMA public FROM {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'REVOKE CREATE ON DATABASE {quote(database_name)} FROM {quote(runtime_role)}'))
|
||||
await conn.execute(
|
||||
text(f'REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) FROM {quote(runtime_role)}')
|
||||
)
|
||||
await conn.execute(text(f'REVOKE SET ON PARAMETER session_replication_role FROM {quote(runtime_role)}'))
|
||||
await conn.execute(text('DROP EXTENSION IF EXISTS dblink CASCADE'))
|
||||
await conn.execute(text('DROP EXTENSION IF EXISTS hstore CASCADE'))
|
||||
await conn.execute(text(f'DROP FOREIGN DATA WRAPPER IF EXISTS {quote(foreign_wrapper)} CASCADE'))
|
||||
await conn.execute(text(f'DROP FUNCTION IF EXISTS public.{quote(owned_routine)}()'))
|
||||
security_definer_is_extension_member = await conn.scalar(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_depend dependency
|
||||
JOIN pg_extension extension ON extension.oid = dependency.refobjid
|
||||
WHERE dependency.classid = 'pg_proc'::regclass
|
||||
AND dependency.objid = to_regprocedure(:routine)
|
||||
AND dependency.refclassid = 'pg_extension'::regclass
|
||||
AND dependency.deptype = 'e'
|
||||
AND extension.extname = 'vector'
|
||||
)
|
||||
"""
|
||||
),
|
||||
{'routine': f'public.{security_definer}()'},
|
||||
)
|
||||
if security_definer_is_extension_member:
|
||||
await conn.execute(text(f'ALTER EXTENSION vector DROP FUNCTION public.{quote(security_definer)}()'))
|
||||
await conn.execute(text(f'DROP FUNCTION IF EXISTS public.{quote(security_definer)}()'))
|
||||
await conn.execute(text(f'DROP VIEW IF EXISTS public.{quote(extra_view)}'))
|
||||
await conn.execute(text(f'DROP SCHEMA IF EXISTS {quote(extra_schema)} CASCADE'))
|
||||
await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(delegated_role)}'))
|
||||
await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
|
||||
|
||||
|
||||
async def test_direct_postgres_head_stamp_fails_without_business_schema(
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
) -> None:
|
||||
await run_alembic_stamp(postgres_engine, '0012_plugin_identity')
|
||||
|
||||
with pytest.raises(RuntimeError, match='requires the knowledge_bases table'):
|
||||
await run_alembic_upgrade(postgres_engine)
|
||||
|
||||
assert await get_alembic_current(postgres_engine) == '0012_plugin_identity'
|
||||
|
||||
|
||||
async def test_release_entrypoint_fails_immediately_when_another_job_holds_lock(
|
||||
postgres_url: str,
|
||||
postgres_engine: AsyncEngine,
|
||||
clean_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_restore_postgres_registry(monkeypatch)
|
||||
ap = _application(postgres_url)
|
||||
|
||||
async with postgres_engine.connect() as lock_connection:
|
||||
assert (
|
||||
await lock_connection.scalar(
|
||||
text('SELECT pg_try_advisory_lock(:lock_id)'),
|
||||
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
is True
|
||||
)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match='already holds the advisory lock'):
|
||||
await release_migration.run_cloud_release_migration(
|
||||
ap,
|
||||
environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
|
||||
)
|
||||
finally:
|
||||
assert (
|
||||
await lock_connection.scalar(
|
||||
text('SELECT pg_advisory_unlock(:lock_id)'),
|
||||
{'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
assert await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()) == []
|
||||
@@ -195,10 +195,16 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO plugin_settings '
|
||||
'(workspace_uuid, plugin_author, plugin_name, enabled) '
|
||||
"VALUES (:workspace_uuid, 'author', 'plugin', 1)"
|
||||
'(workspace_uuid, plugin_author, plugin_name, enabled, '
|
||||
'installation_uuid, artifact_digest, runtime_revision) '
|
||||
"VALUES (:workspace_uuid, 'author', 'plugin', 1, "
|
||||
':installation_uuid, :artifact_digest, 1)'
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
{
|
||||
'workspace_uuid': second_workspace_uuid,
|
||||
'installation_uuid': str(uuid.uuid4()),
|
||||
'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
|
||||
},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot_plugin.box.backend import BaseSandboxBackend
|
||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||
from langbot_plugin.box.errors import BoxAdmissionError
|
||||
from langbot_plugin.box.models import (
|
||||
BoxExecutionResult,
|
||||
BoxExecutionStatus,
|
||||
BoxNetworkMode,
|
||||
BoxSessionInfo,
|
||||
BoxSpec,
|
||||
)
|
||||
from langbot_plugin.box.runtime import BoxRuntime
|
||||
from langbot_plugin.box.server import BoxServerHandler
|
||||
from langbot_plugin.runtime.io.handler import Handler
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot.pkg.cloud.entitlements import (
|
||||
EntitlementResolver,
|
||||
EntitlementSnapshot,
|
||||
EntitlementUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
_UTC = dt.timezone.utc
|
||||
|
||||
|
||||
class _AdmissionBackend(BaseSandboxBackend):
|
||||
name = 'nsjail'
|
||||
|
||||
def __init__(self, logger):
|
||||
super().__init__(logger)
|
||||
self.started_specs: list[BoxSpec] = []
|
||||
self.stopped_sessions: list[str] = []
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
async def get_readiness(self, *, workspace_path=None, strict=False) -> dict:
|
||||
return {
|
||||
'available': True,
|
||||
'cgroup_v2': True,
|
||||
'namespace_isolation': True,
|
||||
'mount_isolation': True,
|
||||
'network_isolation': True,
|
||||
'hard_workspace_quota': True,
|
||||
'hard_skill_storage_quota': True,
|
||||
'bounded_ephemeral_storage': True,
|
||||
'inode_quota': True,
|
||||
}
|
||||
|
||||
async def start_session(self, spec: BoxSpec) -> BoxSessionInfo:
|
||||
self.started_specs.append(spec)
|
||||
now = dt.datetime.now(_UTC)
|
||||
return BoxSessionInfo(
|
||||
session_id=spec.session_id,
|
||||
backend_name=self.name,
|
||||
backend_session_id=f'jail-{len(self.started_specs)}',
|
||||
image=spec.image,
|
||||
network=spec.network,
|
||||
host_path=spec.host_path,
|
||||
host_path_mode=spec.host_path_mode,
|
||||
mount_path=spec.mount_path,
|
||||
persistent=spec.persistent,
|
||||
cpus=spec.cpus,
|
||||
memory_mb=spec.memory_mb,
|
||||
pids_limit=spec.pids_limit,
|
||||
read_only_rootfs=spec.read_only_rootfs,
|
||||
workspace_quota_mb=spec.workspace_quota_mb,
|
||||
created_at=now,
|
||||
last_used_at=now,
|
||||
)
|
||||
|
||||
async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResult:
|
||||
await asyncio.sleep(0)
|
||||
return BoxExecutionResult(
|
||||
session_id=session.session_id,
|
||||
backend_name=self.name,
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout=spec.cmd,
|
||||
stderr='',
|
||||
duration_ms=1,
|
||||
)
|
||||
|
||||
async def stop_session(self, session: BoxSessionInfo):
|
||||
self.stopped_sessions.append(session.session_id)
|
||||
|
||||
|
||||
class _QueueConnection:
|
||||
def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]):
|
||||
self._rx = rx
|
||||
self._tx = tx
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
await self._tx.put(message)
|
||||
|
||||
async def receive(self) -> str:
|
||||
return await self._rx.get()
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _rpc_client(runtime: BoxRuntime):
|
||||
client_to_server: asyncio.Queue[str] = asyncio.Queue()
|
||||
server_to_client: asyncio.Queue[str] = asyncio.Queue()
|
||||
client_connection = _QueueConnection(server_to_client, client_to_server)
|
||||
server_connection = _QueueConnection(client_to_server, server_to_client)
|
||||
server_handler = BoxServerHandler(
|
||||
server_connection,
|
||||
runtime,
|
||||
host_control_authenticated=True,
|
||||
trusted_instance_uuid='instance-a',
|
||||
)
|
||||
server_task = asyncio.create_task(server_handler.run())
|
||||
client_handler = Handler(client_connection)
|
||||
client_task = asyncio.create_task(client_handler.run())
|
||||
client = ActionRPCBoxClient(logger=Mock())
|
||||
client.set_handler(client_handler)
|
||||
return client, server_task, client_task
|
||||
|
||||
|
||||
class _Entitlements:
|
||||
def __init__(self):
|
||||
self.snapshots: dict[str, EntitlementSnapshot] = {}
|
||||
|
||||
async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
|
||||
return self.snapshots[workspace_uuid]
|
||||
|
||||
|
||||
def _snapshot(workspace_uuid: str, *, revision: int = 1, managed: bool = True) -> EntitlementSnapshot:
|
||||
return EntitlementSnapshot(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
entitlement_revision=revision,
|
||||
status='active',
|
||||
not_before=1,
|
||||
expires_at=4_000_000_000,
|
||||
features={'managed_sandbox': managed},
|
||||
limits={'managed_sandbox_sessions': 1 if managed else 0},
|
||||
)
|
||||
|
||||
|
||||
def _context(workspace_uuid: str, *, revision: int = 1) -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=1,
|
||||
entitlement_revision=revision,
|
||||
)
|
||||
|
||||
|
||||
def _query(context: ExecutionContext, query_id: int):
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=query_id,
|
||||
bot_uuid='bot-a',
|
||||
pipeline_uuid='pipeline-a',
|
||||
launcher_type='person',
|
||||
launcher_id=f'user-{query_id}',
|
||||
variables={},
|
||||
)
|
||||
object.__setattr__(query, 'instance_uuid', context.instance_uuid)
|
||||
object.__setattr__(query, 'workspace_uuid', context.workspace_uuid)
|
||||
object.__setattr__(query, 'placement_generation', context.placement_generation)
|
||||
object.__setattr__(query, '_execution_context', context)
|
||||
return query
|
||||
|
||||
|
||||
async def _stack(tmp_path):
|
||||
shared_root = tmp_path / 'shared-box'
|
||||
workspace_root = shared_root / 'workspaces'
|
||||
workspace_root.mkdir(parents=True)
|
||||
box_config = {
|
||||
'enabled': True,
|
||||
'backend': 'nsjail',
|
||||
'runtime': {'endpoint': 'ws://langbot-box:5410'},
|
||||
'local': {
|
||||
'profile': 'default',
|
||||
'host_root': str(shared_root),
|
||||
'default_workspace': str(workspace_root),
|
||||
'allowed_mount_roots': [str(shared_root)],
|
||||
},
|
||||
'admission': {
|
||||
'required': True,
|
||||
'logical_session_id': 'global',
|
||||
'required_backend': 'nsjail',
|
||||
'max_sessions': 1,
|
||||
'max_managed_processes': 0,
|
||||
'max_grant_ttl_sec': 300,
|
||||
'max_timeout_sec': 60,
|
||||
'cpus': 0.5,
|
||||
'memory_mb': 256,
|
||||
'pids_limit': 64,
|
||||
'read_only_rootfs': True,
|
||||
'workspace_quota_mb': 32,
|
||||
'readiness_cache_sec': 0,
|
||||
},
|
||||
}
|
||||
logger = Mock()
|
||||
backend = _AdmissionBackend(logger)
|
||||
runtime = BoxRuntime(logger, backends=[backend])
|
||||
runtime.init(box_config)
|
||||
await runtime.initialize()
|
||||
client, server_task, client_task = await _rpc_client(runtime)
|
||||
|
||||
entitlements = _Entitlements()
|
||||
workspace_service = SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
get_execution_binding=AsyncMock(
|
||||
side_effect=lambda workspace_uuid, expected_generation: SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=expected_generation,
|
||||
)
|
||||
),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
logger=logger,
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=True),
|
||||
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
|
||||
workspace_service=workspace_service,
|
||||
instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}),
|
||||
)
|
||||
service = BoxService(app, client=client)
|
||||
await service.initialize()
|
||||
return service, runtime, backend, entitlements, server_task, client_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_first_use_creates_one_persistent_global_session(tmp_path):
|
||||
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||
context = _context('workspace-a')
|
||||
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
|
||||
try:
|
||||
first, second = await asyncio.gather(
|
||||
service.execute_tool({'command': 'echo first'}, _query(context, 1)),
|
||||
service.execute_tool({'command': 'echo second'}, _query(context, 2)),
|
||||
)
|
||||
|
||||
assert first['session_id'] == 'global'
|
||||
assert second['session_id'] == 'global'
|
||||
assert len(backend.started_specs) == 1
|
||||
spec = backend.started_specs[0]
|
||||
assert spec.persistent is True
|
||||
assert spec.network == BoxNetworkMode.OFF
|
||||
assert spec.cpus == 0.5
|
||||
assert spec.memory_mb == 256
|
||||
assert spec.pids_limit == 64
|
||||
assert spec.workspace_quota_mb == 32
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entitlement_loss_revokes_and_closes_existing_global_session(tmp_path):
|
||||
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||
context = _context('workspace-a')
|
||||
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid, revision=1)
|
||||
try:
|
||||
await service.execute_tool({'command': 'true'}, _query(context, 1))
|
||||
assert len(runtime.get_sessions()) == 1
|
||||
|
||||
entitlements.snapshots[context.workspace_uuid] = _snapshot(
|
||||
context.workspace_uuid,
|
||||
revision=2,
|
||||
managed=False,
|
||||
)
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await service.execute_tool({'command': 'true'}, _query(context, 2))
|
||||
|
||||
assert runtime.get_sessions() == []
|
||||
assert len(backend.stopped_sessions) == 1
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path):
|
||||
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||
first = _context('workspace-a')
|
||||
second = _context('workspace-b')
|
||||
entitlements.snapshots[first.workspace_uuid] = _snapshot(first.workspace_uuid)
|
||||
entitlements.snapshots[second.workspace_uuid] = _snapshot(second.workspace_uuid)
|
||||
try:
|
||||
result_a = await service.execute_tool({'command': 'tenant-a'}, _query(first, 1))
|
||||
result_b = await service.execute_tool({'command': 'tenant-b'}, _query(second, 2))
|
||||
|
||||
assert result_a['session_id'] == result_b['session_id'] == 'global'
|
||||
assert len(backend.started_specs) == 2
|
||||
assert backend.started_specs[0].session_id != backend.started_specs[1].session_id
|
||||
assert backend.started_specs[0].host_path != backend.started_specs[1].host_path
|
||||
assert len(runtime.get_sessions()) == 2
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path):
|
||||
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||
first = _context('workspace-a')
|
||||
second = _context('workspace-b')
|
||||
ineligible = _context('workspace-free')
|
||||
entitlements.snapshots[first.workspace_uuid] = _snapshot(first.workspace_uuid)
|
||||
entitlements.snapshots[second.workspace_uuid] = _snapshot(second.workspace_uuid)
|
||||
entitlements.snapshots[ineligible.workspace_uuid] = _snapshot(
|
||||
ineligible.workspace_uuid,
|
||||
managed=False,
|
||||
)
|
||||
try:
|
||||
private = await service.create_skill(
|
||||
second,
|
||||
{
|
||||
'name': 'private',
|
||||
'instructions': 'workspace-b secret',
|
||||
},
|
||||
)
|
||||
own_skill = await service.create_skill(
|
||||
first,
|
||||
{
|
||||
'name': 'runner',
|
||||
'instructions': 'Run scripts/main.py',
|
||||
},
|
||||
)
|
||||
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
|
||||
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
|
||||
refreshed_skill = await service.get_skill(first, 'runner')
|
||||
assert refreshed_skill is not None
|
||||
assert refreshed_skill['python_project'] is True
|
||||
await service.execute_tool(
|
||||
{
|
||||
'command': 'python /workspace/.skills/runner/scripts/main.py',
|
||||
'workdir': '/workspace/.skills/runner',
|
||||
},
|
||||
_query(first, 91),
|
||||
skill_name='runner',
|
||||
)
|
||||
|
||||
mounted_spec = backend.started_specs[-1]
|
||||
assert len(mounted_spec.extra_mounts) == 1
|
||||
assert mounted_spec.extra_mounts[0].host_path == own_skill['package_root']
|
||||
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
|
||||
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
|
||||
|
||||
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
|
||||
await service.scan_skill_directory(first, private['package_root'])
|
||||
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
|
||||
await service.create_skill(
|
||||
first,
|
||||
{
|
||||
'name': 'stolen',
|
||||
'package_root': private['package_root'],
|
||||
},
|
||||
)
|
||||
|
||||
assert await service.get_skill(first, 'private') is None
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await service.list_skills(ineligible)
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forged_plan_network_session_and_managed_process_never_reach_runtime(tmp_path):
|
||||
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||
context = _context('workspace-a')
|
||||
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
|
||||
query = _query(context, 1)
|
||||
try:
|
||||
with pytest.raises(BoxAdmissionError, match='host-controlled'):
|
||||
await service.execute_spec_payload(
|
||||
{'cmd': 'true', 'session_id': 'global', 'plan': 'pro'},
|
||||
query,
|
||||
)
|
||||
with pytest.raises(BoxAdmissionError, match='network access is disabled'):
|
||||
await service.execute_spec_payload(
|
||||
{'cmd': 'true', 'session_id': 'global', 'network': 'on'},
|
||||
query,
|
||||
)
|
||||
with pytest.raises(BoxAdmissionError, match='session_id is runtime-owned'):
|
||||
await service.execute_spec_payload(
|
||||
{'cmd': 'true', 'session_id': 'attacker'},
|
||||
query,
|
||||
)
|
||||
with pytest.raises(BoxAdmissionError, match='Managed processes are disabled'):
|
||||
await service.start_managed_process(
|
||||
context,
|
||||
'global',
|
||||
{'command': 'sleep', 'args': ['60']},
|
||||
)
|
||||
|
||||
assert backend.started_specs == []
|
||||
assert runtime.get_sessions() == []
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
await runtime.shutdown()
|
||||
@@ -46,6 +46,13 @@ def build_ap() -> SimpleNamespace:
|
||||
)
|
||||
|
||||
ap.apikey_service = SimpleNamespace(authenticate_api_key=authenticate_api_key)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def tenant_scope(workspace_uuid: str):
|
||||
assert workspace_uuid == 'workspace-1'
|
||||
yield
|
||||
|
||||
ap.persistence_mgr = SimpleNamespace(tenant_scope=tenant_scope)
|
||||
ap.bot_service = SimpleNamespace(
|
||||
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}])
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -101,6 +102,60 @@ async def test_public_webhook_error_uses_same_generic_error_contract():
|
||||
assert 'do-not-return' not in (await response.get_data(as_text=True))
|
||||
|
||||
|
||||
async def test_public_webhook_carries_scope_without_holding_database_session():
|
||||
class ScopeOnlyPersistenceManager:
|
||||
mode = SimpleNamespace(value='cloud_runtime')
|
||||
|
||||
def __init__(self):
|
||||
self.active_workspace = None
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def tenant_scope(self, workspace_uuid):
|
||||
self.active_workspace = workspace_uuid
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.active_workspace = None
|
||||
|
||||
def current_session(self):
|
||||
return None
|
||||
|
||||
persistence_mgr = ScopeOnlyPersistenceManager()
|
||||
workspace_uuid = '00000000-0000-0000-0000-00000000000a'
|
||||
bot_uuid = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
class Adapter:
|
||||
async def handle_unified_webhook(self, **_kwargs):
|
||||
assert persistence_mgr.active_workspace == workspace_uuid
|
||||
assert persistence_mgr.current_session() is None
|
||||
return {'ok': True}
|
||||
|
||||
async def get_execution_binding(resolved_workspace_uuid, expected_generation=None):
|
||||
assert resolved_workspace_uuid == workspace_uuid
|
||||
assert expected_generation == 4
|
||||
|
||||
runtime_bot = SimpleNamespace(
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=4,
|
||||
enable=True,
|
||||
adapter=Adapter(),
|
||||
)
|
||||
application = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
persistence_mgr=persistence_mgr,
|
||||
platform_mgr=SimpleNamespace(resolve_public_bot=AsyncMock(return_value=runtime_bot)),
|
||||
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
|
||||
)
|
||||
quart_app = quart.Quart(__name__)
|
||||
await WebhookRouterGroup(application, quart_app).initialize()
|
||||
|
||||
response = await quart_app.test_client().post(f'/bots/{bot_uuid}')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert await response.get_json() == {'ok': True}
|
||||
assert persistence_mgr.active_workspace is None
|
||||
|
||||
|
||||
async def test_authentication_failure_does_not_return_internal_exception_text():
|
||||
logger = Mock()
|
||||
application = SimpleNamespace(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.controller import group
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_authenticated_route_does_not_hold_database_session_during_external_wait():
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
observations: list[bool] = []
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
persistence = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
persistence.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
|
||||
class BlockingRouter(group.RouterGroup):
|
||||
name = 'blocking-route-test'
|
||||
path = '/blocking-route-test'
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _():
|
||||
observations.append(persistence.current_session() is None)
|
||||
entered.set()
|
||||
await release.wait()
|
||||
observations.append(persistence.current_session() is None)
|
||||
return self.success(data={})
|
||||
|
||||
account = SimpleNamespace(uuid='account-a', user='owner@example.com')
|
||||
access = SimpleNamespace(
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
workspace=SimpleNamespace(uuid='workspace-a'),
|
||||
membership=SimpleNamespace(uuid='membership-a', role='owner', projection_revision=1),
|
||||
)
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=persistence,
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=False),
|
||||
user_service=SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account)),
|
||||
workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
|
||||
logger=Mock(),
|
||||
)
|
||||
quart_app = quart.Quart(__name__)
|
||||
await BlockingRouter(application, quart_app).initialize()
|
||||
client = quart_app.test_client()
|
||||
|
||||
request = asyncio.create_task(
|
||||
client.get(
|
||||
'/blocking-route-test',
|
||||
headers={'Authorization': 'Bearer token', 'X-Workspace-Id': 'workspace-a'},
|
||||
)
|
||||
)
|
||||
try:
|
||||
await entered.wait()
|
||||
assert observations == [True]
|
||||
release.set()
|
||||
response = await request
|
||||
assert response.status_code == 200
|
||||
assert observations == [True, True]
|
||||
finally:
|
||||
release.set()
|
||||
if not request.done():
|
||||
await request
|
||||
await engine.dispose()
|
||||
@@ -166,6 +166,29 @@ async def test_revoked_expired_and_unknown_keys_fail_closed(api_key_context):
|
||||
assert await service.authenticate_api_key(expired_secret) is None
|
||||
|
||||
|
||||
async def test_revoke_winning_last_used_update_race_fails_authentication(api_key_context):
|
||||
application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Racing revoke')
|
||||
original_execute = application.persistence_mgr.execute_async
|
||||
injected_revoke = False
|
||||
|
||||
async def execute_with_revoke(statement, *args, **kwargs):
|
||||
nonlocal injected_revoke
|
||||
if (
|
||||
not injected_revoke
|
||||
and isinstance(statement, sqlalchemy.sql.dml.Update)
|
||||
and statement.table.name == ApiKey.__tablename__
|
||||
):
|
||||
injected_revoke = True
|
||||
await original_execute(sqlalchemy.update(ApiKey).where(ApiKey.id == created['id']).values(status='revoked'))
|
||||
return await original_execute(statement, *args, **kwargs)
|
||||
|
||||
application.persistence_mgr.execute_async = execute_with_revoke
|
||||
|
||||
assert await service.authenticate_api_key(created['key']) is None
|
||||
assert injected_revoke is True
|
||||
|
||||
|
||||
async def test_cross_workspace_crud_and_secret_guessing_are_isolated(api_key_context):
|
||||
application, service, first_context, engine = api_key_context
|
||||
second_workspace_uuid = str(uuid.uuid4())
|
||||
|
||||
@@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/maintenance.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch, MagicMock
|
||||
from types import SimpleNamespace
|
||||
@@ -196,6 +197,51 @@ class TestMaintenanceServiceCleanupExpiredFiles:
|
||||
assert ap.logger.warning.called
|
||||
assert 'uploaded_files' in result
|
||||
|
||||
async def test_cloud_cleanup_carries_scope_without_holding_database_session(self):
|
||||
class ScopeOnlyPersistenceManager:
|
||||
mode = SimpleNamespace(value='cloud_runtime')
|
||||
|
||||
def __init__(self):
|
||||
self.active_workspace = None
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def tenant_scope(self, workspace_uuid):
|
||||
self.active_workspace = workspace_uuid
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.active_workspace = None
|
||||
|
||||
def current_session(self):
|
||||
return None
|
||||
|
||||
persistence_mgr = ScopeOnlyPersistenceManager()
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=persistence_mgr,
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
logger=SimpleNamespace(warning=Mock()),
|
||||
)
|
||||
service = MaintenanceService(application)
|
||||
|
||||
async def cleanup_uploads(_context, _retention_days):
|
||||
assert persistence_mgr.active_workspace == TEST_CONTEXT.workspace_uuid
|
||||
assert persistence_mgr.current_session() is None
|
||||
return 2
|
||||
|
||||
def cleanup_logs(_retention_days):
|
||||
assert persistence_mgr.active_workspace == TEST_CONTEXT.workspace_uuid
|
||||
assert persistence_mgr.current_session() is None
|
||||
return 1
|
||||
|
||||
service._cleanup_expired_uploaded_files = cleanup_uploads
|
||||
service._cleanup_expired_log_files = cleanup_logs
|
||||
|
||||
assert await service.cleanup_expired_files(TEST_CONTEXT) == {
|
||||
'uploaded_files': 2,
|
||||
'log_files': 1,
|
||||
}
|
||||
assert persistence_mgr.active_workspace is None
|
||||
|
||||
|
||||
class TestMaintenanceServiceGetStorageAnalysis:
|
||||
"""Tests for get_storage_analysis method."""
|
||||
|
||||
@@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/mcp.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, MagicMock
|
||||
@@ -485,6 +486,54 @@ class TestMCPServiceCreateMCPServer:
|
||||
# Verify - host_mcp_server was called
|
||||
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
|
||||
|
||||
async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self):
|
||||
"""The Runtime must not observe a server row that can still roll back."""
|
||||
|
||||
gate = asyncio.get_running_loop().create_future()
|
||||
|
||||
class PersistenceManagerStub:
|
||||
def create_after_commit_gate(self):
|
||||
return gate
|
||||
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = PersistenceManagerStub()
|
||||
ap.instance_config = SimpleNamespace(data={'system': {'limitation': {'max_extensions': -1}}})
|
||||
observed = []
|
||||
|
||||
async def host_mcp_server(context, config):
|
||||
observed.append((context, config))
|
||||
|
||||
ap.tool_mgr = SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(
|
||||
host_mcp_server=host_mcp_server,
|
||||
_hosted_mcp_tasks=[],
|
||||
)
|
||||
)
|
||||
server_entity = _create_mock_mcp_server(server_uuid='new-uuid', enable=True)
|
||||
results = [
|
||||
_create_mock_result([]),
|
||||
Mock(),
|
||||
_create_mock_result(first_item=server_entity),
|
||||
]
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=results)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
|
||||
)
|
||||
service = _service(ap)
|
||||
|
||||
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
|
||||
await asyncio.sleep(0)
|
||||
assert observed == []
|
||||
|
||||
gate.set_result(None)
|
||||
await ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks[0]
|
||||
assert observed == [
|
||||
(
|
||||
_CONTEXT,
|
||||
{'uuid': 'new-uuid', 'name': 'New Server', 'enable': True},
|
||||
)
|
||||
]
|
||||
|
||||
async def test_create_mcp_server_disabled_no_load(self):
|
||||
"""Does not load server when disabled."""
|
||||
# Setup
|
||||
|
||||
@@ -11,7 +11,9 @@ from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -136,6 +138,26 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||
|
||||
|
||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
message_id = await _record_message(service, context, 'tool context')
|
||||
|
||||
await service.record_tool_call(
|
||||
context,
|
||||
tool_name='search',
|
||||
tool_source='native',
|
||||
duration=12,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
tool_calls, total = await service.get_tool_calls(context)
|
||||
assert total == 1
|
||||
assert tool_calls[0]['bot_id'] == 'same-bot'
|
||||
assert tool_calls[0]['pipeline_id'] == 'same-pipeline'
|
||||
assert tool_calls[0]['session_id'] == 'same-session'
|
||||
assert tool_calls[0]['message_id'] == message_id
|
||||
|
||||
|
||||
async def test_feedback_upsert_and_cancel_are_workspace_scoped(service):
|
||||
context_a = _context(WORKSPACE_A)
|
||||
context_b = _context(WORKSPACE_B)
|
||||
@@ -152,3 +174,58 @@ async def test_feedback_upsert_and_cancel_are_workspace_scoped(service):
|
||||
await service.record_feedback(context_a, feedback_id='same-feedback', feedback_type=3)
|
||||
assert (await service.get_feedback_stats(context_a))['total_feedback'] == 0
|
||||
assert (await service.get_feedback_stats(context_b))['total_feedback'] == 1
|
||||
|
||||
|
||||
async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
|
||||
engine = create_async_engine(
|
||||
f'sqlite+aiosqlite:///{tmp_path / "monitoring-cleanup.db"}',
|
||||
connect_args={'timeout': 0.1},
|
||||
)
|
||||
application = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'database': {'use': 'sqlite'}}),
|
||||
)
|
||||
manager = PersistenceManager(application)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
application.persistence_mgr = manager
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace).values(
|
||||
uuid=WORKSPACE_A,
|
||||
instance_uuid='instance',
|
||||
name='A',
|
||||
slug='a',
|
||||
source='cloud_projection',
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringMessage).values(
|
||||
id='expired-message',
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
- datetime.timedelta(days=30),
|
||||
bot_id='bot',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline',
|
||||
pipeline_name='Pipeline',
|
||||
message_content='expired',
|
||||
session_id='session',
|
||||
status='success',
|
||||
level='info',
|
||||
)
|
||||
)
|
||||
|
||||
deleted = await MonitoringService(application).cleanup_expired_records(
|
||||
_context(WORKSPACE_A),
|
||||
retention_days=1,
|
||||
)
|
||||
|
||||
assert deleted['monitoring_messages'] == 1
|
||||
async with engine.connect() as connection:
|
||||
remaining = await connection.scalar(
|
||||
sqlalchemy.select(sqlalchemy.func.count()).select_from(MonitoringMessage)
|
||||
)
|
||||
assert remaining == 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -45,9 +45,16 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
|
||||
if key_exists
|
||||
else None
|
||||
)
|
||||
query_result = Mock()
|
||||
query_result.first.return_value = key
|
||||
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(side_effect=[query_result, Mock(rowcount=1)]))
|
||||
discovery_result = Mock()
|
||||
discovery_result.first.return_value = key
|
||||
query_results = [discovery_result]
|
||||
if key_exists:
|
||||
scoped_result = Mock()
|
||||
scoped_result.first.return_value = key
|
||||
update_result = Mock()
|
||||
update_result.scalar_one_or_none.return_value = key.id
|
||||
query_results.extend([scoped_result, update_result])
|
||||
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(side_effect=query_results))
|
||||
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
|
||||
workspace_service = SimpleNamespace(
|
||||
get_execution_binding=AsyncMock(
|
||||
@@ -69,7 +76,7 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
|
||||
result = await service.verify_api_key('lbk_valid_format')
|
||||
|
||||
assert result is key_exists
|
||||
assert persistence_mgr.execute_async.await_count == (2 if key_exists else 1)
|
||||
assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1)
|
||||
if key_exists:
|
||||
workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a')
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.service.apikey import ApiKeyIdentity
|
||||
from langbot.pkg.api.mcp.context import get_request_context
|
||||
from langbot.pkg.api.mcp.mount import MCPMount
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_mount_keeps_request_context_but_no_session_during_stream_wait(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "mcp-short-scope.db"}')
|
||||
table = sa.Table('mcp_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
checked_out = 0
|
||||
|
||||
def on_checkout(*_args):
|
||||
nonlocal checked_out
|
||||
checked_out += 1
|
||||
|
||||
def on_checkin(*_args):
|
||||
nonlocal checked_out
|
||||
checked_out -= 1
|
||||
|
||||
sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
|
||||
sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
identity = ApiKeyIdentity(
|
||||
instance_uuid='instance-1',
|
||||
workspace_uuid='workspace-1',
|
||||
placement_generation=7,
|
||||
api_key_uuid='key-1',
|
||||
permissions=frozenset({'pipelines:read'}),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=identity)),
|
||||
persistence_mgr=manager,
|
||||
deployment_admission=None,
|
||||
deployment=None,
|
||||
)
|
||||
stream_waiting = asyncio.Event()
|
||||
release_stream = asyncio.Event()
|
||||
observations: list[tuple[str, str, bool]] = []
|
||||
|
||||
async def fake_mcp_asgi(scope, receive, send):
|
||||
del scope, receive
|
||||
context = get_request_context()
|
||||
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
||||
assert manager.current_session() is None
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
observations.append((context.request_id, context.workspace_uuid, manager.current_session() is None))
|
||||
stream_waiting.set()
|
||||
await release_stream.wait()
|
||||
preserved_context = get_request_context()
|
||||
observations.append(
|
||||
(
|
||||
preserved_context.request_id,
|
||||
preserved_context.workspace_uuid,
|
||||
manager.current_session() is None,
|
||||
)
|
||||
)
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
await send({'type': 'http.response.start', 'status': 200, 'headers': []})
|
||||
await send({'type': 'http.response.body', 'body': b'{}'})
|
||||
|
||||
async def unused_quart_asgi(scope, receive, send):
|
||||
del scope, receive, send
|
||||
raise AssertionError('MCP request was routed to Quart')
|
||||
|
||||
mount = MCPMount.__new__(MCPMount)
|
||||
mount.ap = app
|
||||
mount._mcp_asgi = fake_mcp_asgi
|
||||
sent_messages: list[dict] = []
|
||||
|
||||
async def receive():
|
||||
return {'type': 'http.request', 'body': b'', 'more_body': False}
|
||||
|
||||
async def send(message):
|
||||
sent_messages.append(message)
|
||||
|
||||
async def release_after_observation() -> None:
|
||||
await asyncio.wait_for(stream_waiting.wait(), timeout=2)
|
||||
assert checked_out == 0
|
||||
release_stream.set()
|
||||
|
||||
release_task = asyncio.create_task(release_after_observation())
|
||||
await mount.wrap(unused_quart_asgi)(
|
||||
{
|
||||
'type': 'http',
|
||||
'path': '/mcp',
|
||||
'headers': [(b'x-api-key', b'secret')],
|
||||
},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
await release_task
|
||||
|
||||
assert sent_messages[0]['status'] == 200
|
||||
assert len(observations) == 2
|
||||
assert observations[0] == observations[1]
|
||||
assert observations[0][1:] == ('workspace-1', True)
|
||||
assert checked_out == 0
|
||||
assert manager.current_scope() is None
|
||||
with pytest.raises(RuntimeError, match='context is unavailable'):
|
||||
get_request_context()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -106,3 +107,33 @@ async def test_background_plugin_operation_refences_captured_generation(plugin_r
|
||||
await router._run_fenced_plugin_operation(CONTEXT, operation)
|
||||
|
||||
operation.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(plugin_router_cls):
|
||||
scopes = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def tenant_uow(workspace_uuid):
|
||||
scopes.append(workspace_uuid)
|
||||
yield
|
||||
|
||||
connector = SimpleNamespace(
|
||||
require_workspace_context=AsyncMock(side_effect=lambda context: context),
|
||||
)
|
||||
operation = AsyncMock(return_value='done')
|
||||
router = object.__new__(plugin_router_cls)
|
||||
router.ap = SimpleNamespace(
|
||||
plugin_connector=connector,
|
||||
persistence_mgr=SimpleNamespace(
|
||||
mode=SimpleNamespace(value='cloud_runtime'),
|
||||
tenant_uow=tenant_uow,
|
||||
),
|
||||
)
|
||||
|
||||
result = await router._run_fenced_plugin_operation(CONTEXT, operation)
|
||||
|
||||
assert result == 'done'
|
||||
assert scopes == [CONTEXT.workspace_uuid]
|
||||
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
||||
operation.assert_awaited_once()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import (
|
||||
PrincipalContext,
|
||||
PrincipalType,
|
||||
RequestContext,
|
||||
WorkspaceContext,
|
||||
)
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_pipeline_lookup_opens_workspace_uow_after_auth_scope_closed() -> None:
|
||||
workspace_uuid = 'workspace-a'
|
||||
scopes: list[str] = []
|
||||
in_scope = False
|
||||
|
||||
@asynccontextmanager
|
||||
async def tenant_uow(selected_workspace_uuid: str):
|
||||
nonlocal in_scope
|
||||
assert not in_scope
|
||||
in_scope = True
|
||||
scopes.append(selected_workspace_uuid)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
in_scope = False
|
||||
|
||||
async def get_pipeline(_context, _pipeline_uuid):
|
||||
assert in_scope
|
||||
return {'uuid': 'pipeline-a'}
|
||||
|
||||
adapter = Mock()
|
||||
router = object.__new__(WebSocketChatRouterGroup)
|
||||
router.ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(
|
||||
mode=SimpleNamespace(value='cloud_runtime'),
|
||||
tenant_uow=tenant_uow,
|
||||
),
|
||||
pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(side_effect=get_pipeline)),
|
||||
platform_mgr=SimpleNamespace(get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=adapter))),
|
||||
)
|
||||
request_context = RequestContext(
|
||||
instance_uuid='instance-a',
|
||||
placement_generation=1,
|
||||
request_id='request-a',
|
||||
auth_type='user_token',
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.ACCOUNT,
|
||||
account_uuid='account-a',
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=workspace_uuid,
|
||||
membership_uuid='membership-a',
|
||||
role='owner',
|
||||
permissions=frozenset(),
|
||||
),
|
||||
)
|
||||
|
||||
result = await router._get_scoped_adapter(request_context, 'pipeline-a')
|
||||
|
||||
assert result is adapter
|
||||
assert scopes == [workspace_uuid]
|
||||
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.box.models import SandboxAdmissionPolicy
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.box.admission import (
|
||||
BoxAdmissionError,
|
||||
SandboxAdmissionController,
|
||||
require_cloud_admission_policy,
|
||||
)
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot.pkg.cloud.entitlements import (
|
||||
EntitlementResolver,
|
||||
EntitlementSnapshot,
|
||||
EntitlementUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
entitlement_revision=7,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(
|
||||
*,
|
||||
revision: int = 7,
|
||||
managed: bool = True,
|
||||
sessions: int = 1,
|
||||
expires_at: int = 2_000,
|
||||
) -> EntitlementSnapshot:
|
||||
return EntitlementSnapshot(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
entitlement_revision=revision,
|
||||
status='active',
|
||||
not_before=1,
|
||||
expires_at=expires_at,
|
||||
features={'managed_sandbox': managed},
|
||||
limits={'managed_sandbox_sessions': sessions},
|
||||
)
|
||||
|
||||
|
||||
def _controller(snapshot: EntitlementSnapshot, *, now: float = 1_000.25):
|
||||
provider = SimpleNamespace(get_workspace_entitlement=AsyncMock(return_value=snapshot))
|
||||
resolver = EntitlementResolver('instance-a', provider)
|
||||
client = SimpleNamespace(
|
||||
upsert_sandbox_admission_grant=AsyncMock(
|
||||
side_effect=lambda grant: {
|
||||
'installed': True,
|
||||
'workspace_uuid': grant.workspace_uuid,
|
||||
'execution_generation': grant.execution_generation,
|
||||
'entitlement_revision': grant.entitlement_revision,
|
||||
'max_sessions': grant.max_sessions,
|
||||
'max_managed_processes': grant.max_managed_processes,
|
||||
}
|
||||
),
|
||||
revoke_sandbox_admission_grant=AsyncMock(
|
||||
side_effect=lambda revocation: {
|
||||
'revoked': True,
|
||||
'workspace_uuid': revocation.workspace_uuid,
|
||||
'entitlement_revision': revocation.entitlement_revision,
|
||||
}
|
||||
),
|
||||
)
|
||||
app = SimpleNamespace(entitlement_resolver=resolver, logger=Mock())
|
||||
controller = SandboxAdmissionController(
|
||||
app,
|
||||
client,
|
||||
policy=SandboxAdmissionPolicy(required=True, max_grant_ttl_sec=300),
|
||||
wall_time=lambda: now,
|
||||
)
|
||||
return controller, client, provider
|
||||
|
||||
|
||||
def test_cloud_admission_policy_requires_positive_workspace_quota():
|
||||
with pytest.raises(BoxAdmissionError, match='workspace quota must be a positive integer'):
|
||||
require_cloud_admission_policy(
|
||||
{
|
||||
'required': True,
|
||||
'workspace_quota_mb': 0,
|
||||
}
|
||||
)
|
||||
|
||||
policy = require_cloud_admission_policy(
|
||||
{
|
||||
'required': True,
|
||||
'workspace_quota_mb': 32,
|
||||
}
|
||||
)
|
||||
assert policy.workspace_quota_mb == 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_generic_entitlement_installs_short_lived_numeric_grant():
|
||||
controller, client, provider = _controller(_snapshot())
|
||||
|
||||
grant = await controller.require(_CONTEXT)
|
||||
|
||||
assert grant.instance_uuid == _CONTEXT.instance_uuid
|
||||
assert grant.workspace_uuid == _CONTEXT.workspace_uuid
|
||||
assert grant.execution_generation == _CONTEXT.placement_generation
|
||||
assert grant.entitlement_revision == 7
|
||||
assert grant.max_sessions == 1
|
||||
assert grant.max_managed_processes == 0
|
||||
assert grant.expires_at == dt.datetime.fromtimestamp(1_300, tz=_UTC)
|
||||
assert (grant.expires_at - dt.datetime.fromtimestamp(1_000.25, tz=_UTC)).total_seconds() < 300
|
||||
provider.get_workspace_entitlement.assert_awaited_once_with('workspace-a')
|
||||
client.upsert_sandbox_admission_grant.assert_awaited_once_with(grant)
|
||||
client.revoke_sandbox_admission_grant.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'snapshot',
|
||||
[
|
||||
_snapshot(managed=False),
|
||||
_snapshot(sessions=0),
|
||||
_snapshot(sessions=2),
|
||||
],
|
||||
)
|
||||
async def test_non_eligible_entitlement_revokes_and_fails_closed(snapshot):
|
||||
controller, client, _provider = _controller(snapshot)
|
||||
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await controller.require(_CONTEXT)
|
||||
|
||||
client.upsert_sandbox_admission_grant.assert_not_awaited()
|
||||
revocation = client.revoke_sandbox_admission_grant.await_args.args[0]
|
||||
assert revocation.entitlement_revision == snapshot.entitlement_revision
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_entitlement_failure_does_not_tombstone_valid_revision():
|
||||
controller, client, provider = _controller(_snapshot())
|
||||
await controller.require(_CONTEXT)
|
||||
provider.get_workspace_entitlement.side_effect = RuntimeError('control plane unavailable')
|
||||
|
||||
with pytest.raises(RuntimeError, match='control plane unavailable'):
|
||||
await controller.require(_CONTEXT)
|
||||
|
||||
client.revoke_sandbox_admission_grant.assert_not_awaited()
|
||||
|
||||
provider.get_workspace_entitlement.side_effect = None
|
||||
provider.get_workspace_entitlement.return_value = _snapshot()
|
||||
recovered = await controller.require(_CONTEXT)
|
||||
assert recovered.entitlement_revision == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_receipt_mismatch_is_revoked_and_never_admitted():
|
||||
controller, client, _provider = _controller(_snapshot())
|
||||
client.upsert_sandbox_admission_grant.return_value = {'installed': True, 'workspace_uuid': 'other'}
|
||||
client.upsert_sandbox_admission_grant.side_effect = None
|
||||
|
||||
with pytest.raises(Exception, match='invalid sandbox admission receipt'):
|
||||
await controller.require(_CONTEXT)
|
||||
|
||||
client.revoke_sandbox_admission_grant.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authoritative_cancelled_revision_is_revoked():
|
||||
cancelled = _snapshot(revision=8).model_copy(update={'status': 'cancelled'})
|
||||
controller, client, _provider = _controller(cancelled)
|
||||
|
||||
with pytest.raises(EntitlementUnavailableError, match='not active'):
|
||||
await controller.require(_CONTEXT)
|
||||
|
||||
revocation = client.revoke_sandbox_admission_grant.await_args.args[0]
|
||||
assert revocation.entitlement_revision == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_box_readiness_failure_aborts_service_initialization(tmp_path):
|
||||
workspace_root = tmp_path / 'box' / 'workspaces'
|
||||
workspace_root.mkdir(parents=True)
|
||||
box_config = {
|
||||
'enabled': True,
|
||||
'backend': 'nsjail',
|
||||
'runtime': {'endpoint': 'ws://box:5410'},
|
||||
'local': {
|
||||
'host_root': str(tmp_path / 'box'),
|
||||
'default_workspace': str(workspace_root),
|
||||
'allowed_mount_roots': [str(tmp_path / 'box')],
|
||||
},
|
||||
'admission': {
|
||||
'required': True,
|
||||
'logical_session_id': 'global',
|
||||
'required_backend': 'nsjail',
|
||||
'max_sessions': 1,
|
||||
'max_managed_processes': 0,
|
||||
'max_grant_ttl_sec': 300,
|
||||
'workspace_quota_mb': 32,
|
||||
},
|
||||
}
|
||||
client = SimpleNamespace(
|
||||
initialize=AsyncMock(),
|
||||
verify_shared_workspace=AsyncMock(
|
||||
side_effect=lambda marker_name: {
|
||||
'marker_name': marker_name,
|
||||
'size': (workspace_root / marker_name).stat().st_size,
|
||||
'sha256': hashlib.sha256((workspace_root / marker_name).read_bytes()).hexdigest(),
|
||||
}
|
||||
),
|
||||
get_backend_info=AsyncMock(return_value={'name': 'docker', 'available': True}),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=True),
|
||||
entitlement_resolver=Mock(),
|
||||
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
|
||||
instance_config=SimpleNamespace(data={'box': box_config}),
|
||||
)
|
||||
service = BoxService(app, client=client)
|
||||
|
||||
with pytest.raises(Exception, match='nsjail isolation readiness failed'):
|
||||
await service.initialize()
|
||||
|
||||
assert service.available is False
|
||||
@@ -117,6 +117,9 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient):
|
||||
async def init(self, config: dict) -> None:
|
||||
self._runtime.init(config)
|
||||
|
||||
async def verify_shared_workspace(self, marker_name: str) -> dict:
|
||||
return self._runtime.verify_shared_workspace(marker_name)
|
||||
|
||||
|
||||
class FakeBackend(BaseSandboxBackend):
|
||||
def __init__(self, logger: Mock, available: bool = True):
|
||||
@@ -322,6 +325,43 @@ def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_
|
||||
assert not (host_root / 'default').exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_initialize_fails_when_core_and_runtime_volumes_are_separated(tmp_path):
|
||||
logger = Mock()
|
||||
core_root = tmp_path / 'core-box'
|
||||
runtime_root = tmp_path / 'runtime-box'
|
||||
(core_root / 'default').mkdir(parents=True)
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
runtime.init(
|
||||
{
|
||||
'local': {
|
||||
'host_root': str(runtime_root),
|
||||
'default_workspace': 'default',
|
||||
'allowed_mount_roots': [str(runtime_root)],
|
||||
}
|
||||
}
|
||||
)
|
||||
app = make_app(logger, host_root=str(core_root))
|
||||
app.deployment = SimpleNamespace(multi_workspace_enabled=True)
|
||||
app.instance_config.data['box'].update(
|
||||
{
|
||||
'backend': 'nsjail',
|
||||
'admission': {'required': True, 'workspace_quota_mb': 32},
|
||||
}
|
||||
)
|
||||
service = BoxService(
|
||||
app,
|
||||
client=_InProcessBoxRuntimeClient(logger, runtime),
|
||||
)
|
||||
|
||||
with pytest.raises(BoxValidationError, match='shared durable Workspace volume'):
|
||||
await service.initialize()
|
||||
|
||||
assert service.available is False
|
||||
assert list((core_root / 'default').glob('.langbot-box-volume-probe-*')) == []
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path):
|
||||
logger = Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
@@ -1888,7 +1928,7 @@ class TestInboundOutboundRoundTrip:
|
||||
assert '/workspace/inbox/' in parameters['command']
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '["/workspace/inbox/42/image_1.png"]',
|
||||
'stdout': '["/workspace/inbox/query-42/image_1.png"]',
|
||||
'stderr': '',
|
||||
}
|
||||
|
||||
@@ -1898,7 +1938,7 @@ class TestInboundOutboundRoundTrip:
|
||||
assert len(descriptors) == 1
|
||||
d = descriptors[0]
|
||||
assert d['type'] == 'Image'
|
||||
assert d['path'] == '/workspace/inbox/42/image_1.png'
|
||||
assert d['path'] == '/workspace/inbox/query-42/image_1.png'
|
||||
assert d['size'] == len(img_bytes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2011,7 +2051,7 @@ class TestAttachmentHostPath:
|
||||
assert d['type'] == 'Image'
|
||||
assert d['size'] == len(big)
|
||||
# File actually landed on the host workspace.
|
||||
host_file = os.path.join(ws, 'inbox', str(query.query_id), d['name'])
|
||||
host_file = os.path.join(ws, 'inbox', str(query.query_uuid), d['name'])
|
||||
assert os.path.isfile(host_file)
|
||||
assert open(host_file, 'rb').read() == big
|
||||
|
||||
@@ -2023,7 +2063,7 @@ class TestAttachmentHostPath:
|
||||
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
# Seed a stale file under the same query_id (simulates webchat id reuse).
|
||||
stale_dir = os.path.join(ws, 'inbox', '42')
|
||||
stale_dir = os.path.join(ws, 'inbox', 'query-42')
|
||||
os.makedirs(stale_dir, exist_ok=True)
|
||||
open(os.path.join(stale_dir, 'image_1.png'), 'wb').write(b'STALE-OLD-IMAGE')
|
||||
|
||||
@@ -2039,11 +2079,40 @@ class TestAttachmentHostPath:
|
||||
# No leftover content from the stale image.
|
||||
assert b'STALE-OLD-IMAGE' not in open(host_file, 'rb').read()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path):
|
||||
import base64
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
other_workspace = tmp_path / 'other-workspace'
|
||||
other_workspace.mkdir()
|
||||
protected = other_workspace / 'protected.txt'
|
||||
protected.write_bytes(b'workspace-b-secret')
|
||||
inbox = os.path.join(ws, 'inbox')
|
||||
os.makedirs(inbox, exist_ok=True)
|
||||
os.symlink(other_workspace, os.path.join(inbox, 'query-42'))
|
||||
|
||||
query = make_query()
|
||||
payload = b'workspace-a-input'
|
||||
query.message_chain = platform_message.MessageChain(
|
||||
[platform_message.File(name='input.bin', base64=base64.b64encode(payload).decode())]
|
||||
)
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
|
||||
descriptors = await service.materialize_inbound_attachments(query)
|
||||
|
||||
assert descriptors[0]['path'] == '/workspace/inbox/query-42/input.bin'
|
||||
assert protected.read_bytes() == b'workspace-b-secret'
|
||||
assert not os.path.islink(os.path.join(inbox, 'query-42'))
|
||||
assert open(os.path.join(inbox, 'query-42', 'input.bin'), 'rb').read() == payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_reads_host_and_clears(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_id))
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
# A large file that would be truncated on the exec/stdout path:
|
||||
big_png = b'\x89PNG\r\n\x1a\n' + b'y' * (400 * 1024)
|
||||
@@ -2063,13 +2132,69 @@ class TestAttachmentHostPath:
|
||||
# Outbox cleared after collection.
|
||||
assert os.listdir(outbox) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_host_never_follows_query_or_file_symlinks(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
other_workspace = tmp_path / 'other-workspace'
|
||||
other_workspace.mkdir()
|
||||
secret = other_workspace / 'secret.txt'
|
||||
secret.write_bytes(b'workspace-b-secret')
|
||||
outbox_root = os.path.join(ws, 'outbox')
|
||||
os.makedirs(outbox_root, exist_ok=True)
|
||||
|
||||
# A hostile query-directory replacement is rejected rather than read.
|
||||
query_dir = os.path.join(outbox_root, str(query.query_uuid))
|
||||
os.symlink(other_workspace, query_dir)
|
||||
with pytest.raises(BoxValidationError, match='symbolic link'):
|
||||
await service.collect_outbound_attachments(query)
|
||||
assert secret.read_bytes() == b'workspace-b-secret'
|
||||
|
||||
os.unlink(query_dir)
|
||||
os.makedirs(query_dir)
|
||||
os.symlink(secret, os.path.join(query_dir, 'leak.txt'))
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
assert secret.read_bytes() == b'workspace-b-secret'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_host_fails_closed_on_inode_bomb(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
harmless_target = tmp_path / 'harmless-target'
|
||||
harmless_target.write_bytes(b'x')
|
||||
# Symlinks do not count toward the 20 returned files, so this proves
|
||||
# traversal itself has a bounded entry budget.
|
||||
for index in range(513):
|
||||
os.symlink(harmless_target, os.path.join(outbox, f'entry-{index}'))
|
||||
|
||||
with pytest.raises(BoxValidationError, match='symbolic link'):
|
||||
await service.collect_outbound_attachments(query)
|
||||
assert harmless_target.read_bytes() == b'x'
|
||||
|
||||
def test_host_attachment_directories_use_query_uuid_not_process_local_id(self, tmp_path):
|
||||
service, _ws = self._service_with_workspace(tmp_path)
|
||||
first = make_query(query_id=7)
|
||||
second = make_query(query_id=7)
|
||||
object.__setattr__(first, 'query_uuid', 'replica-a-query')
|
||||
object.__setattr__(second, 'query_uuid', 'replica-b-query')
|
||||
|
||||
first_path = service._host_query_dir(service.OUTBOX_SUBDIR, first)
|
||||
second_path = service._host_query_dir(service.OUTBOX_SUBDIR, second)
|
||||
|
||||
assert first_path is not None and first_path.endswith('/outbox/replica-a-query')
|
||||
assert second_path is not None and second_path.endswith('/outbox/replica-b-query')
|
||||
assert first_path != second_path
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_empty_clears_stale_host_dir(self, tmp_path):
|
||||
# Reusing a query_id (counter resets on restart) must not re-send files
|
||||
# a previous run left in the outbox: an empty collection still clears it.
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_id))
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
# Stale file from a prior turn; the agent produced nothing this turn —
|
||||
# but _read_outbox_host would still pick it up, so collection must drop
|
||||
@@ -2118,16 +2243,16 @@ class TestAttachmentHostPath:
|
||||
os.makedirs(os.path.join(outbox, '0'), exist_ok=True)
|
||||
|
||||
# Simulate a host delete that cannot remove the root-owned outbox.
|
||||
import shutil as _shutil
|
||||
from langbot.pkg.box import secure_fs
|
||||
|
||||
real_rmtree = _shutil.rmtree
|
||||
real_purge = secure_fs.purge_subdirectory
|
||||
|
||||
def fake_rmtree(path, *a, **k):
|
||||
if os.path.abspath(path) == os.path.abspath(outbox):
|
||||
return # "permission denied" — silently leaves the dir
|
||||
return real_rmtree(path, *a, **k)
|
||||
def fake_purge(root, subdir):
|
||||
if os.path.abspath(os.path.join(root, subdir)) == os.path.abspath(outbox):
|
||||
raise PermissionError('root-owned')
|
||||
return real_purge(root, subdir)
|
||||
|
||||
monkeypatch.setattr(_shutil, 'rmtree', fake_rmtree)
|
||||
monkeypatch.setattr(secure_fs, 'purge_subdirectory', fake_purge)
|
||||
|
||||
service.build_spec = Mock()
|
||||
service.client.execute = AsyncMock()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.cloud.bootstrap import (
|
||||
CloudBootstrapError,
|
||||
CloudRuntimeUnavailableError,
|
||||
DeploymentAdmissionGuard,
|
||||
OpenSourceDeployment,
|
||||
VerifiedCloudDeployment,
|
||||
resolve_deployment,
|
||||
@@ -62,8 +65,34 @@ class _EntryPoints(list):
|
||||
def _cloud_config() -> dict:
|
||||
return {
|
||||
'database': {'use': 'postgresql'},
|
||||
'vdb': {'use': 'pgvector'},
|
||||
'vdb': {
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [384, 768, 1536],
|
||||
},
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': False}},
|
||||
'plugin': {'worker': {'require_hard_limits': True}},
|
||||
'box': {
|
||||
'enabled': True,
|
||||
'backend': 'nsjail',
|
||||
'runtime': {'endpoint': 'ws://langbot-box:5410'},
|
||||
'admission': {
|
||||
'required': True,
|
||||
'logical_session_id': 'global',
|
||||
'required_backend': 'nsjail',
|
||||
'max_sessions': 1,
|
||||
'max_managed_processes': 0,
|
||||
'max_grant_ttl_sec': 300,
|
||||
'workspace_quota_mb': 32,
|
||||
},
|
||||
'local': {
|
||||
'host_root': '/var/lib/langbot/box',
|
||||
'default_workspace': '/var/lib/langbot/box/workspaces',
|
||||
'allowed_mount_roots': ['/var/lib/langbot/box'],
|
||||
},
|
||||
},
|
||||
# Proves mutable product metadata does not participate in selection.
|
||||
'system': {'edition': 'community'},
|
||||
}
|
||||
@@ -99,6 +128,7 @@ async def test_verified_closed_entry_point_activates_cloud_policy():
|
||||
('database', {'use': 'sqlite'}, 'database.use=postgresql'),
|
||||
('vdb', {'use': 'chroma'}, 'vdb.use=pgvector'),
|
||||
('mcp', {'stdio': {'enabled': True}}, 'mcp.stdio.enabled=false'),
|
||||
('plugin', {'worker': {'require_hard_limits': False}}, 'plugin.worker.require_hard_limits=true'),
|
||||
],
|
||||
)
|
||||
async def test_cloud_runtime_config_is_fail_closed(field, value, message):
|
||||
@@ -114,6 +144,79 @@ async def test_cloud_runtime_config_is_fail_closed(field, value, message):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('pgvector_config', 'message'),
|
||||
[
|
||||
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
|
||||
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
|
||||
({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
|
||||
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
|
||||
],
|
||||
)
|
||||
async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
|
||||
config = _cloud_config()
|
||||
config['vdb']['pgvector'] = pgvector_config
|
||||
|
||||
with pytest.raises(CloudBootstrapError, match=message):
|
||||
await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=config,
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('mutate', 'message'),
|
||||
[
|
||||
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
|
||||
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
|
||||
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
||||
(
|
||||
lambda config: config['box']['admission'].update(max_sessions=2),
|
||||
'grant-enforced Box admission',
|
||||
),
|
||||
(
|
||||
lambda config: config['box']['admission'].update(max_managed_processes=1),
|
||||
'zero managed processes',
|
||||
),
|
||||
(
|
||||
lambda config: config['box']['admission'].update(max_grant_ttl_sec=301),
|
||||
'max_grant_ttl_sec',
|
||||
),
|
||||
(
|
||||
lambda config: config['box']['admission'].update(workspace_quota_mb=0),
|
||||
'workspace_quota_mb must be a positive integer',
|
||||
),
|
||||
(
|
||||
lambda config: config['box']['admission'].update(workspace_quota_mb=True),
|
||||
'workspace_quota_mb must be a positive integer',
|
||||
),
|
||||
(
|
||||
lambda config: config['box']['local'].update(default_workspace='relative/workspaces'),
|
||||
'default_workspace must be an absolute',
|
||||
),
|
||||
(
|
||||
lambda config: config['box']['local'].update(
|
||||
default_workspace='/other/workspaces',
|
||||
),
|
||||
'under allowed_mount_roots',
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_cloud_box_contract_is_fail_closed(mutate, message):
|
||||
config = _cloud_config()
|
||||
mutate(config)
|
||||
|
||||
with pytest.raises(CloudBootstrapError, match=message):
|
||||
await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=config,
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
async def test_invalid_provider_never_falls_back_to_oss():
|
||||
provider = SimpleNamespace(bootstrap=lambda **_: object())
|
||||
|
||||
@@ -134,3 +237,55 @@ async def test_duplicate_closed_providers_fail_closed():
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider()), _EntryPoint(_Provider())]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
async def test_deployment_admission_expires_even_after_wall_clock_rollback():
|
||||
wall = [1_000.0]
|
||||
monotonic = [50.0]
|
||||
deployment = dataclasses.replace(
|
||||
_Provider().bootstrap(instance_uuid='instance-a', instance_config={}),
|
||||
expires_at=1_010,
|
||||
)
|
||||
guard = DeploymentAdmissionGuard(
|
||||
'instance-a',
|
||||
deployment,
|
||||
wall_time=lambda: wall[0],
|
||||
monotonic_time=lambda: monotonic[0],
|
||||
)
|
||||
|
||||
assert guard.require_active() is deployment
|
||||
wall[0] = 900.0
|
||||
monotonic[0] = 60.0
|
||||
with pytest.raises(CloudRuntimeUnavailableError, match='expired'):
|
||||
guard.require_active()
|
||||
|
||||
|
||||
async def test_deployment_admission_accepts_only_monotonic_non_conflicting_renewal():
|
||||
wall = [1_000.0]
|
||||
monotonic = [50.0]
|
||||
current = dataclasses.replace(
|
||||
_Provider().bootstrap(instance_uuid='instance-a', instance_config={}),
|
||||
expires_at=1_010,
|
||||
)
|
||||
guard = DeploymentAdmissionGuard(
|
||||
'instance-a',
|
||||
current,
|
||||
wall_time=lambda: wall[0],
|
||||
monotonic_time=lambda: monotonic[0],
|
||||
)
|
||||
renewed = dataclasses.replace(
|
||||
current,
|
||||
manifest_jti='manifest-b',
|
||||
manifest_generation=4,
|
||||
expires_at=2_000,
|
||||
)
|
||||
guard.replace(renewed)
|
||||
assert guard.require_active() is renewed
|
||||
|
||||
rollback = dataclasses.replace(current, manifest_generation=2)
|
||||
with pytest.raises(CloudRuntimeUnavailableError, match='rolled back'):
|
||||
guard.replace(rollback)
|
||||
|
||||
conflicting = dataclasses.replace(renewed, manifest_jti='different')
|
||||
with pytest.raises(CloudRuntimeUnavailableError, match='conflicting'):
|
||||
guard.replace(conflicting)
|
||||
|
||||
@@ -84,3 +84,26 @@ async def test_resolver_rejects_same_revision_with_different_contents():
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
with pytest.raises(EntitlementUnavailableError, match='conflicting contents'):
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_checks_deployment_admission_before_and_after_provider_call():
|
||||
checks = 0
|
||||
|
||||
def require_admission() -> None:
|
||||
nonlocal checks
|
||||
checks += 1
|
||||
if checks == 2:
|
||||
raise RuntimeError('manifest expired during provider call')
|
||||
|
||||
provider = AsyncMock()
|
||||
provider.get_workspace_entitlement = AsyncMock(return_value=_snapshot())
|
||||
resolver = EntitlementResolver(
|
||||
'instance-a',
|
||||
provider,
|
||||
deployment_admission=require_admission,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match='expired during provider call'):
|
||||
await resolver.resolve('workspace-a', now=150)
|
||||
assert checks == 2
|
||||
|
||||
@@ -35,6 +35,22 @@ class TestApplyEnvOverridesToConfig:
|
||||
|
||||
assert result['system']['name'] == 'custom_name'
|
||||
|
||||
def test_override_log_never_prints_secret_value(self, capsys):
|
||||
"""Environment-backed credentials must not be copied into logs."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
secret = 'database-password-that-must-not-leak'
|
||||
cfg = {'database': {'postgresql': {'password': ''}}}
|
||||
env = {'DATABASE__POSTGRESQL__PASSWORD': secret}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert result['database']['postgresql']['password'] == secret
|
||||
assert 'DATABASE__POSTGRESQL__PASSWORD' in captured
|
||||
assert secret not in captured
|
||||
|
||||
def test_override_int_value(self):
|
||||
"""Test overriding an int value with proper conversion."""
|
||||
load_config = get_load_config_module()
|
||||
@@ -196,6 +212,19 @@ class TestApplyEnvOverridesToConfig:
|
||||
|
||||
assert result['system']['name'] == 'default'
|
||||
|
||||
def test_skip_env_vars_with_empty_path_segments(self, capsys):
|
||||
"""Platform variables such as __CF_USER_TEXT_ENCODING are not config."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
cfg = {'system': {'name': 'default'}}
|
||||
env = {'__CF_USER_TEXT_ENCODING': '0x1F5:0x0:0x64'}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result == cfg
|
||||
assert capsys.readouterr().out == ''
|
||||
|
||||
def test_nested_config_path(self):
|
||||
"""Test overriding deeply nested config."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import contextvars
|
||||
import sys
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from contextlib import contextmanager
|
||||
@@ -360,6 +361,53 @@ class TestAsyncTaskManager:
|
||||
|
||||
wrapper.cancel()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_does_not_inherit_request_context(self):
|
||||
"""Long-lived tasks must receive identity through explicit arguments."""
|
||||
|
||||
_, _, AsyncTaskManager = get_taskmgr_classes()
|
||||
mock_app = create_mock_app()
|
||||
manager = AsyncTaskManager(mock_app)
|
||||
request_value = contextvars.ContextVar('request_value', default=None)
|
||||
token = request_value.set('request-scoped-transaction')
|
||||
observed = []
|
||||
|
||||
async def detached_task(captured_workspace: str) -> None:
|
||||
observed.append((request_value.get(), captured_workspace))
|
||||
|
||||
try:
|
||||
wrapper = manager.create_task(detached_task('workspace-a'))
|
||||
await wrapper.task
|
||||
finally:
|
||||
request_value.reset(token)
|
||||
|
||||
assert observed == [(None, 'workspace-a')]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_waits_for_registered_transaction_commit(self):
|
||||
_, _, AsyncTaskManager = get_taskmgr_classes()
|
||||
mock_app = create_mock_app()
|
||||
gate = asyncio.get_running_loop().create_future()
|
||||
|
||||
class PersistenceManagerStub:
|
||||
def create_after_commit_gate(self):
|
||||
return gate
|
||||
|
||||
mock_app.persistence_mgr = PersistenceManagerStub()
|
||||
manager = AsyncTaskManager(mock_app)
|
||||
observed = []
|
||||
|
||||
async def background_work() -> None:
|
||||
observed.append('started')
|
||||
|
||||
wrapper = manager.create_task(background_work())
|
||||
await asyncio.sleep(0)
|
||||
assert observed == []
|
||||
|
||||
gate.set_result(None)
|
||||
await wrapper.task
|
||||
assert observed == ['started']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats_counts_correctly(self):
|
||||
"""Test get_stats returns correct counts."""
|
||||
|
||||
@@ -11,9 +11,19 @@ Note: Uses import isolation to break circular import chains.
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_database_manager_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep decorator tests from mutating the process-wide manager registry."""
|
||||
from langbot.pkg.persistence import database
|
||||
|
||||
monkeypatch.setattr(database, 'preregistered_managers', list(database.preregistered_managers))
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
# Persistence manager performs the package's database-manager registration;
|
||||
# importing a concrete manager first would enter the historical app/mgr cycle.
|
||||
from langbot.pkg.persistence import mgr as _persistence_mgr # noqa: F401
|
||||
from langbot.pkg.persistence.databases import postgresql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_manager_parses_explicit_url_without_string_reassembly(monkeypatch) -> None:
|
||||
captured = None
|
||||
sentinel_engine = object()
|
||||
|
||||
def create_engine(url):
|
||||
nonlocal captured
|
||||
captured = url
|
||||
return sentinel_engine
|
||||
|
||||
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'postgresql': {
|
||||
'url': 'postgresql://runtime:p%40ss@db.internal:5432/langbot?sslmode=require',
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager = postgresql.PostgreSQLDatabaseManager(ap)
|
||||
await manager.initialize()
|
||||
|
||||
assert captured.drivername == 'postgresql+asyncpg'
|
||||
assert captured.password == 'p@ss'
|
||||
assert captured.query['ssl'] == 'require'
|
||||
assert 'sslmode' not in captured.query
|
||||
assert manager.engine is sentinel_engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_manager_builds_structured_url_with_special_password(monkeypatch) -> None:
|
||||
captured = None
|
||||
|
||||
def create_engine(url):
|
||||
nonlocal captured
|
||||
captured = url
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'postgresql': {
|
||||
'host': 'db.internal',
|
||||
'port': 5432,
|
||||
'user': 'runtime',
|
||||
'password': 'p@ss:/?#word',
|
||||
'database': 'langbot',
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await postgresql.PostgreSQLDatabaseManager(ap).initialize()
|
||||
|
||||
assert captured.password == 'p@ss:/?#word'
|
||||
assert captured.host == 'db.internal'
|
||||
assert captured.database == 'langbot'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgresql_manager_rejects_non_postgresql_url_without_echoing_secret() -> None:
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'database': {'postgresql': {'url': 'sqlite:///operator-super-secret.db'}}}
|
||||
)
|
||||
)
|
||||
|
||||
manager = postgresql.PostgreSQLDatabaseManager(ap)
|
||||
with pytest.raises(ValueError, match='valid PostgreSQL') as exc_info:
|
||||
await manager.initialize()
|
||||
assert 'operator-super-secret' not in str(exc_info.value)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.__main__ import _build_parser
|
||||
from langbot.pkg.persistence import release_migration
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
|
||||
|
||||
def _cloud_config(*, database_use: str = 'postgresql', runtime_user: str = 'langbot_runtime') -> dict:
|
||||
return {
|
||||
'database': {
|
||||
'use': database_use,
|
||||
'postgresql': {
|
||||
'host': 'runtime-db',
|
||||
'port': 5432,
|
||||
'user': runtime_user,
|
||||
'password': 'runtime-secret',
|
||||
'database': 'langbot',
|
||||
},
|
||||
'cloud_migration': {
|
||||
'operator_dsn_env': 'TEST_LANGBOT_OPERATOR_DSN',
|
||||
},
|
||||
},
|
||||
'vdb': {
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [384, 1536],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _operator_environ(
|
||||
*,
|
||||
user: str = 'langbot_migrator',
|
||||
database: str = 'langbot',
|
||||
host: str = 'runtime-db',
|
||||
port: int = 5432,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
'TEST_LANGBOT_OPERATOR_DSN': (f'postgresql://{user}:operator%40secret@{host}:{port}/{database}?sslmode=require')
|
||||
}
|
||||
|
||||
|
||||
def test_cloud_migration_cli_is_explicit() -> None:
|
||||
args = _build_parser().parse_args(['migrate', '--cloud'])
|
||||
assert args.command == 'migrate'
|
||||
assert args.cloud is True
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_build_parser().parse_args(['migrate'])
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
|
||||
def test_operator_url_is_separate_and_preserves_escaped_secret() -> None:
|
||||
url = release_migration._operator_database_url(
|
||||
_cloud_config(),
|
||||
environ=_operator_environ(),
|
||||
)
|
||||
|
||||
assert url.drivername == 'postgresql+asyncpg'
|
||||
assert url.username == 'langbot_migrator'
|
||||
assert url.password == 'operator@secret'
|
||||
assert url.host == 'runtime-db'
|
||||
assert url.port == 5432
|
||||
assert url.database == 'langbot'
|
||||
assert url.query['ssl'] == 'require'
|
||||
assert 'sslmode' not in url.query
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('config', 'environ', 'message'),
|
||||
[
|
||||
(_cloud_config(database_use='sqlite'), _operator_environ(), 'SQLite fallback is forbidden'),
|
||||
(_cloud_config(), {}, 'requires the operator DSN'),
|
||||
(_cloud_config(), {'TEST_LANGBOT_OPERATOR_DSN': 'not a secret://operator-password'}, 'DSN is invalid'),
|
||||
(
|
||||
_cloud_config(),
|
||||
{'TEST_LANGBOT_OPERATOR_DSN': 'postgresql://operator:secret@runtime-db:not-a-port/langbot'},
|
||||
'DSN is invalid',
|
||||
),
|
||||
(_cloud_config(), _operator_environ(user='langbot_runtime'), 'distinct operator role'),
|
||||
(_cloud_config(), _operator_environ(database='another_database'), 'configured runtime database'),
|
||||
(_cloud_config(), _operator_environ(host='other-cluster'), 'runtime PostgreSQL endpoint'),
|
||||
(_cloud_config(), _operator_environ(port=6432), 'runtime PostgreSQL endpoint'),
|
||||
],
|
||||
)
|
||||
def test_operator_url_rejects_unsafe_configuration(config: dict, environ: dict[str, str], message: str) -> None:
|
||||
with pytest.raises(release_migration.CloudReleaseMigrationConfigurationError, match=message) as exc_info:
|
||||
release_migration._operator_database_url(config, environ=environ)
|
||||
assert 'operator-password' not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_migration_disposes_operator_engine_on_failure(monkeypatch) -> None:
|
||||
engine = SimpleNamespace(dispose=AsyncMock())
|
||||
manager = SimpleNamespace(
|
||||
db=SimpleNamespace(engine=engine),
|
||||
initialize=AsyncMock(side_effect=RuntimeError('migration failed')),
|
||||
)
|
||||
|
||||
def manager_factory(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return manager
|
||||
|
||||
monkeypatch.setattr(release_migration, 'PersistenceManager', manager_factory)
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data=_cloud_config()),
|
||||
logger=logging.getLogger('release-migration-disposal-test'),
|
||||
persistence_mgr=None,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match='migration failed'):
|
||||
await release_migration.run_cloud_release_migration(ap, environ=_operator_environ())
|
||||
|
||||
assert ap.persistence_mgr is manager
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_mode_rejects_sqlite_before_schema_changes(tmp_path, monkeypatch) -> None:
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr_module
|
||||
from langbot.pkg.persistence.databases.sqlite import SQLiteDatabaseManager
|
||||
|
||||
monkeypatch.setattr(persistence_mgr_module.database, 'preregistered_managers', [SQLiteDatabaseManager])
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'sqlite',
|
||||
'sqlite': {'path': str(tmp_path / 'must-not-migrate.db')},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger('release-migration-sqlite-rejection-test'),
|
||||
)
|
||||
manager = PersistenceManager(ap, mode=PersistenceMode.RELEASE_MIGRATION)
|
||||
with pytest.raises(RuntimeError, match='requires PostgreSQL'):
|
||||
await manager.initialize()
|
||||
await manager.get_db_engine().dispose()
|
||||
|
||||
engine = sqlalchemy.create_engine(f'sqlite:///{tmp_path / "must-not-migrate.db"}')
|
||||
try:
|
||||
assert sqlalchemy.inspect(engine).get_table_names() == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -1,11 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.core.task_boundary import create_detached_task
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
|
||||
from langbot.pkg.persistence.tenant_uow import (
|
||||
CrossScopeTransactionError,
|
||||
PersistenceScopeKind,
|
||||
TenantScopeRequiredError,
|
||||
TenantUnitOfWork,
|
||||
TransactionRollbackOnlyError,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -61,3 +73,340 @@ def test_persistence_mode_must_be_a_trusted_enum() -> None:
|
||||
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
assert manager.mode is PersistenceMode.CLOUD_RUNTIME
|
||||
|
||||
|
||||
async def test_manager_reuses_one_session_and_rejects_cross_workspace(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-uow.db"}')
|
||||
table = sa.Table(
|
||||
'manager_rows',
|
||||
sa.MetaData(),
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
)
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace or discovery'):
|
||||
await manager.execute_async(sa.select(table))
|
||||
|
||||
async with manager.tenant_uow('workspace-a') as outer:
|
||||
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
||||
async with manager.tenant_uow('workspace-a') as inner:
|
||||
assert inner.session is outer.session
|
||||
assert manager.current_session() is outer.session
|
||||
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
|
||||
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
|
||||
async with manager.tenant_uow('workspace-b'):
|
||||
pass
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_manager_scoped_execute_preserves_core_row_and_scalar_contract(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-result-contract.db"}')
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(User.__table__.create)
|
||||
await conn.execute(
|
||||
sa.insert(User).values(
|
||||
uuid='account-a',
|
||||
user='owner@example.com',
|
||||
normalized_email='owner@example.com',
|
||||
password='hashed-password',
|
||||
)
|
||||
)
|
||||
|
||||
async with manager.tenant_uow('workspace-a') as uow:
|
||||
result = await manager.execute_async(sa.select(User))
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert row.uuid == 'account-a'
|
||||
assert row.user == 'owner@example.com'
|
||||
|
||||
scalar_result = await manager.execute_async(sa.select(User.uuid))
|
||||
assert scalar_result.scalar_one() == 'account-a'
|
||||
|
||||
list_result = await manager.execute_async(sa.select(User.user))
|
||||
assert list_result.scalars().all() == ['owner@example.com']
|
||||
|
||||
# Direct UoW execution remains an ORM API for code that opts into it.
|
||||
orm_result = await uow.execute(sa.select(User))
|
||||
assert orm_result.scalars().one().uuid == 'account-a'
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_transaction_free_tenant_scope_opens_one_short_uow_per_database_call(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope.db"}')
|
||||
table = sa.Table(
|
||||
'short_scope_rows',
|
||||
sa.MetaData(),
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
)
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
async with manager.tenant_scope('workspace-a'):
|
||||
assert manager.current_scope() is not None
|
||||
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
||||
assert manager.current_scope().settings == (('langbot.workspace_uuid', 'workspace-a'),)
|
||||
assert manager.current_session() is None
|
||||
|
||||
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
||||
assert manager.current_session() is None
|
||||
|
||||
# The first statement has already committed. Long external waits
|
||||
# inside this boundary retain only identity, never a DB session.
|
||||
await asyncio.sleep(0)
|
||||
assert manager.current_session() is None
|
||||
|
||||
assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
|
||||
assert manager.current_session() is None
|
||||
|
||||
async with manager.tenant_scope('workspace-a'):
|
||||
assert manager.current_session() is None
|
||||
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
|
||||
async with manager.tenant_scope('workspace-b'):
|
||||
pass
|
||||
with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
|
||||
async with manager.tenant_uow('workspace-b'):
|
||||
pass
|
||||
|
||||
assert manager.current_scope() is None
|
||||
with pytest.raises(TenantScopeRequiredError, match='explicit Workspace'):
|
||||
await manager.execute_async(sa.select(table))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_transaction_free_scope_requires_explicit_child_task_scope(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope-child.db"}')
|
||||
table = sa.Table('child_scope_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
async with manager.tenant_scope('workspace-a'):
|
||||
|
||||
async def inherited_access() -> None:
|
||||
await manager.execute_async(sa.select(table))
|
||||
|
||||
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
|
||||
await asyncio.create_task(inherited_access())
|
||||
|
||||
async def explicitly_scoped_access() -> None:
|
||||
async with manager.tenant_scope('workspace-a'):
|
||||
await manager.execute_async(sa.insert(table).values(id=1))
|
||||
assert manager.current_session() is None
|
||||
|
||||
await asyncio.create_task(explicitly_scoped_access())
|
||||
assert manager.current_session() is None
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_caught_nested_failure_marks_outer_transaction_rollback_only(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "rollback-only.db"}')
|
||||
table = sa.Table('rollback_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
await manager.execute_async(sa.insert(table).values(id=1))
|
||||
try:
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
raise ValueError('caught nested failure')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(table))).all() == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('executor_kind', ['manager', 'uow', 'session'])
|
||||
async def test_caught_database_error_rolls_back_and_cancels_after_commit_gate(tmp_path, executor_kind: str) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"db-error-{executor_kind}.db"}')
|
||||
table = sa.Table('unique_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
|
||||
async with manager.tenant_uow('workspace-a') as uow:
|
||||
statement = sa.insert(table).values(id=1)
|
||||
if executor_kind == 'manager':
|
||||
await manager.execute_async(statement)
|
||||
elif executor_kind == 'uow':
|
||||
await uow.execute(statement)
|
||||
else:
|
||||
await uow.session.execute(statement)
|
||||
|
||||
gate = manager.create_after_commit_gate()
|
||||
assert gate is not None
|
||||
try:
|
||||
if executor_kind == 'manager':
|
||||
await manager.execute_async(statement)
|
||||
elif executor_kind == 'uow':
|
||||
await uow.execute(statement)
|
||||
else:
|
||||
await uow.session.execute(statement)
|
||||
except sa.exc.IntegrityError:
|
||||
pass
|
||||
|
||||
assert gate.cancelled()
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(table))).all() == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_child_task_must_open_its_own_explicit_uow(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "child-task.db"}')
|
||||
table = sa.Table(
|
||||
'child_rows',
|
||||
sa.MetaData(),
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
)
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
|
||||
async def inherited_access() -> None:
|
||||
await manager.execute_async(sa.select(table))
|
||||
|
||||
with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
|
||||
await asyncio.create_task(inherited_access())
|
||||
|
||||
async def explicitly_scoped_access() -> None:
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
|
||||
|
||||
await asyncio.create_task(explicitly_scoped_access())
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [2]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_detached_task_starts_without_parent_scope_and_rolls_back_its_uow(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "detached-task.db"}')
|
||||
table = sa.Table(
|
||||
'detached_rows',
|
||||
sa.MetaData(),
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
)
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
|
||||
async def detached_write() -> None:
|
||||
# Before the detached boundary this access raises
|
||||
# CrossScopeTransactionError because asyncio copies the
|
||||
# parent's ActiveScopedTransaction into this child task.
|
||||
assert manager.current_scope() is None
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
|
||||
raise RuntimeError('roll back detached write')
|
||||
|
||||
task = create_detached_task(detached_write())
|
||||
with pytest.raises(RuntimeError, match='roll back detached write'):
|
||||
await task
|
||||
|
||||
await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_after_commit_task_waits_for_commit_and_starts_with_empty_context(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-commit.db"}')
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
request_value = contextvars.ContextVar('after_commit_request_value', default=None)
|
||||
observed = []
|
||||
try:
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
token = request_value.set('request-scope')
|
||||
|
||||
async def after_commit_work() -> None:
|
||||
observed.append((request_value.get(), manager.current_scope()))
|
||||
|
||||
try:
|
||||
task = create_detached_task(
|
||||
after_commit_work(),
|
||||
after_commit_manager=manager,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert observed == []
|
||||
finally:
|
||||
request_value.reset(token)
|
||||
|
||||
await task
|
||||
assert observed == [(None, None)]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_after_commit_task_is_cancelled_and_coroutine_closed_on_rollback(tmp_path) -> None:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-rollback.db"}')
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
started = False
|
||||
|
||||
async def should_not_start() -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
coro = should_not_start()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match='rollback request'):
|
||||
async with manager.tenant_uow('workspace-a'):
|
||||
task = create_detached_task(coro, after_commit_manager=manager)
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
raise RuntimeError('rollback request')
|
||||
|
||||
await asyncio.sleep(0)
|
||||
assert task.cancelled()
|
||||
assert not started
|
||||
assert coro.cr_frame is None
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -13,8 +13,11 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import contextvars
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
@@ -894,17 +897,48 @@ class TestMessageAggregatorWorkspaceIsolation:
|
||||
app = make_aggregator_app()
|
||||
enable_aggregation(app)
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
delayed_flush = AsyncMock()
|
||||
request_value = contextvars.ContextVar('aggregator_request_value', default=None)
|
||||
token = request_value.set('request-scope')
|
||||
observed = []
|
||||
|
||||
async def delayed_flush(*args):
|
||||
observed.append((request_value.get(), args[2]))
|
||||
|
||||
monkeypatch.setattr(agg, '_delayed_flush', delayed_flush)
|
||||
context = execution_context(pipeline_uuid='test-pipeline')
|
||||
|
||||
await agg.add_message(**scoped_message_kwargs(context))
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
await agg.add_message(**scoped_message_kwargs(context))
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
request_value.reset(token)
|
||||
|
||||
delayed_flush.assert_awaited_once()
|
||||
assert delayed_flush.await_args.args[2] is context
|
||||
assert observed == [(None, context)]
|
||||
await agg.flush_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delayed_flush_opens_explicit_workspace_uow(self, monkeypatch):
|
||||
app = make_aggregator_app()
|
||||
app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||
scopes = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def tenant_uow(workspace_uuid):
|
||||
scopes.append(workspace_uuid)
|
||||
yield
|
||||
|
||||
app.persistence_mgr.tenant_uow = tenant_uow
|
||||
agg = get_aggregator_module().MessageAggregator(app)
|
||||
flush = AsyncMock()
|
||||
monkeypatch.setattr(agg, '_flush_buffer', flush)
|
||||
context = execution_context('workspace-a', pipeline_uuid='test-pipeline')
|
||||
key = aggregation_key(context, pipeline_uuid='test-pipeline')
|
||||
|
||||
await agg._delayed_flush(key, 0, context)
|
||||
|
||||
assert scopes == ['workspace-a']
|
||||
flush.assert_awaited_once_with(key, context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_rejects_context_from_another_workspace(self):
|
||||
app = make_aggregator_app()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
||||
from langbot.pkg.pipeline.controller import Controller
|
||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
||||
|
||||
@@ -44,6 +49,79 @@ async def test_controller_drops_stale_query_before_pipeline_lookup(
|
||||
query_pool.condition.notify_all.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_controller_releases_database_connection_during_pipeline_wait(
|
||||
tmp_path,
|
||||
mock_app,
|
||||
sample_query,
|
||||
):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "pipeline-short-scope.db"}')
|
||||
table = sa.Table('pipeline_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
checked_out = 0
|
||||
|
||||
def on_checkout(*_args):
|
||||
nonlocal checked_out
|
||||
checked_out += 1
|
||||
|
||||
def on_checkin(*_args):
|
||||
nonlocal checked_out
|
||||
checked_out -= 1
|
||||
|
||||
sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
|
||||
sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(table.metadata.create_all)
|
||||
|
||||
_prepare_scheduler(mock_app)
|
||||
mock_app.persistence_mgr = manager
|
||||
pipeline_waiting = asyncio.Event()
|
||||
release_pipeline = asyncio.Event()
|
||||
|
||||
async def get_binding(*_args, **_kwargs):
|
||||
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
||||
assert manager.current_session() is None
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
return SimpleNamespace(
|
||||
instance_uuid='test-instance',
|
||||
workspace_uuid='test-workspace',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
async def run_pipeline(_query):
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
pipeline_waiting.set()
|
||||
await release_pipeline.wait()
|
||||
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
||||
assert manager.current_session() is None
|
||||
|
||||
runtime_pipeline = SimpleNamespace(run=AsyncMock(side_effect=run_pipeline))
|
||||
|
||||
async def get_pipeline(*_args, **_kwargs):
|
||||
await manager.execute_async(sa.select(table.c.id))
|
||||
assert manager.current_session() is None
|
||||
return runtime_pipeline
|
||||
|
||||
mock_app.workspace_service.get_execution_binding = AsyncMock(side_effect=get_binding)
|
||||
mock_app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(side_effect=get_pipeline)
|
||||
controller = Controller(mock_app)
|
||||
|
||||
task = asyncio.create_task(controller._process_query(sample_query))
|
||||
await asyncio.wait_for(pipeline_waiting.wait(), timeout=2)
|
||||
assert checked_out == 0
|
||||
assert not task.done()
|
||||
release_pipeline.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
assert checked_out == 0
|
||||
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_controller_revalidates_generation_before_running_pipeline(
|
||||
mock_app,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
@@ -112,3 +115,71 @@ def test_runtime_bot_rejects_workspace_mismatch():
|
||||
logger=SimpleNamespace(),
|
||||
execution_context=_context(WORKSPACE_B, BOT_A),
|
||||
)
|
||||
|
||||
|
||||
class _ScopeOnlyPersistenceManager:
|
||||
mode = SimpleNamespace(value='cloud_runtime')
|
||||
|
||||
def __init__(self):
|
||||
self.active_workspace = None
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def tenant_scope(self, workspace_uuid: str):
|
||||
assert self.active_workspace is None
|
||||
self.active_workspace = workspace_uuid
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.active_workspace = None
|
||||
|
||||
def current_session(self):
|
||||
return None
|
||||
|
||||
|
||||
class _ListenerAdapter:
|
||||
def __init__(self):
|
||||
self.listeners = {}
|
||||
|
||||
def register_listener(self, event_type, listener):
|
||||
self.listeners[event_type] = listener
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_callback_carries_scope_without_holding_database_session():
|
||||
persistence_mgr = _ScopeOnlyPersistenceManager()
|
||||
adapter = _ListenerAdapter()
|
||||
|
||||
async def push_person_message(*_args, **_kwargs):
|
||||
assert persistence_mgr.active_workspace == WORKSPACE_A
|
||||
assert persistence_mgr.current_session() is None
|
||||
return True
|
||||
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=persistence_mgr,
|
||||
workspace_service=_WorkspaceService(),
|
||||
webhook_pusher=SimpleNamespace(push_person_message=push_person_message),
|
||||
)
|
||||
entity = SimpleNamespace(
|
||||
uuid=BOT_A,
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
name='Bot',
|
||||
enable=True,
|
||||
pipeline_routing_rules=[],
|
||||
use_pipeline_uuid=None,
|
||||
)
|
||||
logger = SimpleNamespace(info=AsyncMock(), error=AsyncMock())
|
||||
runtime = RuntimeBot(
|
||||
ap=application,
|
||||
bot_entity=entity,
|
||||
adapter=adapter,
|
||||
logger=logger,
|
||||
execution_context=_context(WORKSPACE_A, BOT_A),
|
||||
)
|
||||
await runtime.initialize()
|
||||
|
||||
listener = adapter.listeners[platform_events.FriendMessage]
|
||||
event = SimpleNamespace(message_chain=[], sender=SimpleNamespace(id='user'))
|
||||
await listener(event, adapter)
|
||||
|
||||
assert persistence_mgr.active_workspace is None
|
||||
logger.info.assert_awaited()
|
||||
|
||||
@@ -9,19 +9,32 @@ Tests cover:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
TEST_ACTION_CONTEXT = ActionContext(
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
TEST_INSTALLATION_BINDING = InstallationBinding(
|
||||
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
|
||||
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
|
||||
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
|
||||
|
||||
def get_connector_module():
|
||||
@@ -37,6 +50,7 @@ def create_mock_app():
|
||||
mock_app.instance_config.data = {'plugin': {'enable': True}}
|
||||
mock_app.persistence_mgr = AsyncMock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.persistence_mgr.tenant_uow = None
|
||||
return mock_app
|
||||
|
||||
|
||||
@@ -47,7 +61,19 @@ def create_mock_connector():
|
||||
async def mock_disconnect_callback(conn):
|
||||
pass
|
||||
|
||||
return connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
|
||||
instance = connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
|
||||
instance._execution_context.set(TEST_EXECUTION_CONTEXT)
|
||||
instance._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
|
||||
instance._target_binding = AsyncMock(return_value=TEST_INSTALLATION_BINDING)
|
||||
instance._load_workspace_settings = AsyncMock(return_value=[])
|
||||
instance.require_workspace_context = AsyncMock(side_effect=lambda context: context)
|
||||
return instance
|
||||
|
||||
|
||||
def configure_handler(connector, runtime_handler):
|
||||
runtime_handler.installation_scope = Mock(side_effect=lambda _binding: nullcontext())
|
||||
connector.handler = runtime_handler
|
||||
return runtime_handler
|
||||
|
||||
|
||||
class TestListPlugins:
|
||||
@@ -76,8 +102,7 @@ class TestListPlugins:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
|
||||
)
|
||||
@@ -86,8 +111,7 @@ class TestListPlugins:
|
||||
|
||||
connector.handler.list_plugins.assert_called_once()
|
||||
assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
|
||||
statement = connector.ap.persistence_mgr.execute_async.await_args.args[0]
|
||||
assert 'workspace-a' in statement.compile().params.values()
|
||||
connector._load_workspace_settings.assert_awaited_once_with(TEST_EXECUTION_CONTEXT)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_by_component_kinds(self):
|
||||
@@ -95,8 +119,7 @@ class TestListPlugins:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
@@ -123,8 +146,7 @@ class TestListPlugins:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.require_bound_action_context = Mock(return_value=TEST_ACTION_CONTEXT)
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_plugins = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
@@ -171,7 +193,7 @@ class TestPluginDiagnostics:
|
||||
'response_sources': response_sources,
|
||||
}
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
|
||||
|
||||
fake_event_ctx = Mock()
|
||||
@@ -215,7 +237,7 @@ class TestPluginDiagnostics:
|
||||
],
|
||||
}
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
|
||||
|
||||
fake_event_ctx = Mock()
|
||||
@@ -238,7 +260,7 @@ class TestPluginDiagnostics:
|
||||
connector_module.context.EventContext.from_event = original_from_event
|
||||
connector_module.context.EventContext.model_validate = original_model_validate
|
||||
|
||||
assert '_response_sources' not in vars(event_ctx)
|
||||
assert event_ctx._response_sources == []
|
||||
assert event_ctx._emitted_plugins == [
|
||||
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
|
||||
]
|
||||
@@ -253,7 +275,7 @@ class TestPluginDiagnostics:
|
||||
mock_app = create_mock_app()
|
||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
|
||||
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
|
||||
|
||||
@@ -262,7 +284,7 @@ class TestPluginDiagnostics:
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_plugin_diagnostic_is_best_effort(self):
|
||||
connector = create_mock_connector()
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found'))
|
||||
|
||||
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
|
||||
@@ -297,7 +319,7 @@ class TestListKnowledgeEngines:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_knowledge_engines = AsyncMock(
|
||||
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
|
||||
)
|
||||
@@ -334,7 +356,7 @@ class TestListParsers:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.list_parsers = AsyncMock(
|
||||
return_value=[{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
|
||||
)
|
||||
@@ -354,7 +376,7 @@ class TestCallParser:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.parse_document = AsyncMock(return_value={'content': 'parsed'})
|
||||
|
||||
result = await connector.call_parser(
|
||||
@@ -381,7 +403,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_ingest_document = AsyncMock(return_value={'status': 'success'})
|
||||
|
||||
result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'})
|
||||
@@ -395,7 +417,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.retrieve_knowledge = AsyncMock(
|
||||
return_value={
|
||||
'results': [
|
||||
@@ -424,7 +446,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.get_rag_creation_schema = AsyncMock(return_value={'properties': {'name': {'type': 'string'}}})
|
||||
|
||||
result = await connector.get_rag_creation_schema('author/engine')
|
||||
@@ -438,7 +460,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.get_rag_retrieval_schema = AsyncMock(
|
||||
return_value={'properties': {'top_k': {'type': 'integer'}}}
|
||||
)
|
||||
@@ -454,7 +476,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_on_kb_create = AsyncMock(return_value={'status': 'ok'})
|
||||
|
||||
await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'})
|
||||
@@ -467,7 +489,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_on_kb_delete = AsyncMock(return_value={'status': 'ok'})
|
||||
|
||||
await connector.rag_on_kb_delete('author/engine', 'kb-uuid')
|
||||
@@ -480,7 +502,7 @@ class TestRAGMethods:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.rag_delete_document = AsyncMock(return_value=True)
|
||||
|
||||
result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid')
|
||||
@@ -574,7 +596,7 @@ class TestGetPluginInfo:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.get_plugin_info = AsyncMock(return_value={'manifest': {'metadata': {'name': 'plugin'}}})
|
||||
|
||||
result = await connector.get_plugin_info('author', 'plugin')
|
||||
@@ -587,17 +609,39 @@ class TestSetPluginConfig:
|
||||
"""Tests for set_plugin_config method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_handler_set_plugin_config(self):
|
||||
"""Test that handler.set_plugin_config is called."""
|
||||
async def test_updates_revision_then_applies_desired_state(self):
|
||||
"""Config changes are fenced by a new runtime revision."""
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
connector.handler.set_plugin_config = AsyncMock(return_value={'status': 'ok'})
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.register_installation_binding = Mock()
|
||||
connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'running'})
|
||||
setting = SimpleNamespace(
|
||||
installation_uuid=TEST_INSTALLATION_BINDING.installation_uuid,
|
||||
runtime_revision=1,
|
||||
artifact_digest=TEST_INSTALLATION_BINDING.artifact_digest,
|
||||
enabled=True,
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
|
||||
)
|
||||
connector._setting_for_plugin = AsyncMock(return_value=(TEST_EXECUTION_CONTEXT, setting))
|
||||
connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
|
||||
|
||||
await connector.set_plugin_config('author', 'plugin', {'setting': 'value'})
|
||||
|
||||
connector.handler.set_plugin_config.assert_called_once_with('author', 'plugin', {'setting': 'value'})
|
||||
applied_binding = connector.handler.apply_plugin_installation.await_args.args[0]
|
||||
assert applied_binding.runtime_revision == 2
|
||||
assert applied_binding.installation_uuid == TEST_INSTALLATION_BINDING.installation_uuid
|
||||
connector.handler.register_installation_binding.assert_called_once_with(
|
||||
applied_binding,
|
||||
plugin_author='author',
|
||||
plugin_name='plugin',
|
||||
)
|
||||
connector.handler.apply_plugin_installation.assert_awaited_once_with(
|
||||
applied_binding,
|
||||
artifact_package=None,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
class TestPingPluginRuntime:
|
||||
@@ -621,7 +665,7 @@ class TestPingPluginRuntime:
|
||||
get_connector_module()
|
||||
connector = create_mock_connector()
|
||||
|
||||
connector.handler = AsyncMock()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector.handler.ping = AsyncMock(return_value={'status': 'ok'})
|
||||
|
||||
await connector.ping_plugin_runtime()
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.runtime.security import (
|
||||
PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
|
||||
PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
|
||||
@@ -91,7 +90,7 @@ async def test_enabled_connector_reports_not_connected_after_workspace_validatio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_resolves_oss_singleton_binding_from_workspace_service():
|
||||
async def test_oss_connector_resolves_singleton_only_for_legacy_callers():
|
||||
connector = make_connector()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
get_local_execution_binding=AsyncMock(
|
||||
@@ -103,31 +102,29 @@ async def test_connector_resolves_oss_singleton_binding_from_workspace_service()
|
||||
)
|
||||
)
|
||||
|
||||
assert await connector._resolve_action_context() == ActionContext(
|
||||
assert await connector._current_execution_context() == ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edition_metadata_cannot_enable_multi_workspace_runtime_binding():
|
||||
def test_edition_metadata_cannot_enable_shared_runtime_profile():
|
||||
connector = make_connector()
|
||||
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=False),
|
||||
get_local_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
),
|
||||
|
||||
assert connector.runtime_profile == 'oss_dev'
|
||||
|
||||
|
||||
def test_closed_deployment_selects_instance_scoped_shared_profile():
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
|
||||
context = await connector._resolve_action_context()
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
assert context.workspace_uuid == 'workspace-a'
|
||||
assert connector.runtime_profile == 'shared'
|
||||
|
||||
|
||||
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
||||
@@ -148,48 +145,61 @@ def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_fails_closed_without_trusted_workspace_service():
|
||||
async def test_oss_legacy_fallback_fails_without_workspace_service():
|
||||
connector = make_connector()
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='Plugin Runtime Workspace binding is unavailable',
|
||||
):
|
||||
await connector._resolve_action_context()
|
||||
with pytest.raises(AttributeError):
|
||||
await connector._current_execution_context()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_connector_never_falls_back_to_ghost_local_workspace():
|
||||
connector = make_connector()
|
||||
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
get_local_binding = AsyncMock()
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
app.workspace_service = SimpleNamespace(
|
||||
get_local_execution_binding=get_local_binding,
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='require an explicit projected Workspace binding',
|
||||
):
|
||||
await connector._resolve_action_context()
|
||||
with pytest.raises(Exception, match='Plugin resource not found'):
|
||||
await connector._current_execution_context()
|
||||
|
||||
get_local_binding.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_connector_initialize_fails_before_opening_unbound_runtime():
|
||||
connector = make_connector()
|
||||
connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
|
||||
connector.ap.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
def test_worker_policy_is_loaded_only_from_instance_configuration():
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'plugin': {
|
||||
'enable': True,
|
||||
'worker': {
|
||||
'max_cpus': 1.5,
|
||||
'max_memory_mb': 768,
|
||||
'max_pids': 64,
|
||||
'max_open_files': 128,
|
||||
'max_file_size_mb': 32,
|
||||
'require_hard_limits': True,
|
||||
},
|
||||
# A plugin-controlled value at any other path is ignored.
|
||||
'manifest': {'max_memory_mb': 99999},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='require an explicit projected Workspace binding',
|
||||
):
|
||||
await connector.initialize()
|
||||
policy = connector._load_worker_policy()
|
||||
|
||||
assert policy.max_cpus == 1.5
|
||||
assert policy.max_memory_mb == 768
|
||||
assert policy.max_pids == 64
|
||||
assert policy.max_open_files == 128
|
||||
assert policy.max_file_size_mb == 32
|
||||
assert policy.require_hard_limits is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -198,17 +208,11 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'plugin': {'enable': True},
|
||||
'system': {'edition': 'cloud'},
|
||||
}
|
||||
)
|
||||
)
|
||||
configured = ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-cloud-a',
|
||||
placement_generation=7,
|
||||
),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
app.workspace_service = SimpleNamespace(
|
||||
policy=SimpleNamespace(multi_workspace_enabled=True),
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
@@ -216,13 +220,19 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
|
||||
placement_generation=7,
|
||||
)
|
||||
),
|
||||
get_local_execution_binding=AsyncMock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock(), action_context=configured)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = SimpleNamespace()
|
||||
connector._synchronize_workspace = AsyncMock()
|
||||
configured = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-cloud-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
assert await connector._resolve_action_context() == configured
|
||||
assert await connector.require_workspace_context(configured) == configured
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
'workspace-cloud-a',
|
||||
expected_generation=7,
|
||||
)
|
||||
app.workspace_service.get_local_execution_binding.assert_not_awaited()
|
||||
connector._synchronize_workspace.assert_awaited_once_with(configured)
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.plugin.connector import (
|
||||
PluginInstallationFailedError,
|
||||
PluginRuntimeConnector,
|
||||
)
|
||||
|
||||
|
||||
def connection_result_connector(execute_async: AsyncMock) -> PluginRuntimeConnector:
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
persistence_mgr=SimpleNamespace(
|
||||
tenant_uow=None,
|
||||
execute_async=execute_async,
|
||||
),
|
||||
logger=Mock(),
|
||||
)
|
||||
return PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
|
||||
def execution_binding(workspace_uuid: str, generation: int = 1) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
)
|
||||
|
||||
|
||||
def plugin_setting(
|
||||
workspace_suffix: str,
|
||||
artifact_digest: str,
|
||||
*,
|
||||
durable: bool = True,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
plugin_author='author',
|
||||
plugin_name=f'plugin-{workspace_suffix}',
|
||||
installation_uuid=f'00000000-0000-4000-8000-0000000000{workspace_suffix}',
|
||||
runtime_revision=1,
|
||||
artifact_digest=artifact_digest,
|
||||
enabled=True,
|
||||
priority=0,
|
||||
created_at=datetime.datetime(2026, 1, 1),
|
||||
install_source='local',
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if durable else {},
|
||||
)
|
||||
|
||||
|
||||
def runtime_handler(
|
||||
*,
|
||||
missing_artifacts: list[str] | None = None,
|
||||
failed_installations: list[dict[str, str]] | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
register_installation_binding=Mock(),
|
||||
unregister_installation_binding=Mock(),
|
||||
reconcile_plugin_installations=AsyncMock(
|
||||
return_value={
|
||||
'applied': [],
|
||||
'removed': [],
|
||||
'missing_artifacts': missing_artifacts or [],
|
||||
'failed_installations': failed_installations or [],
|
||||
}
|
||||
),
|
||||
apply_plugin_installation=AsyncMock(return_value={'state': 'starting'}),
|
||||
installation_scope=Mock(side_effect=lambda _binding: nullcontext()),
|
||||
list_plugins=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
|
||||
def shared_connector(
|
||||
projected_bindings: list[list[SimpleNamespace]],
|
||||
settings: dict[str, list[SimpleNamespace]],
|
||||
) -> PluginRuntimeConnector:
|
||||
async def get_execution_binding(workspace_uuid: str, *, expected_generation: int | None = None):
|
||||
for binding_set in projected_bindings:
|
||||
for binding in binding_set:
|
||||
if binding.workspace_uuid == workspace_uuid:
|
||||
assert expected_generation in (None, binding.placement_generation)
|
||||
return binding
|
||||
raise AssertionError(f'unexpected Workspace {workspace_uuid}')
|
||||
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
workspace_service=SimpleNamespace(
|
||||
list_active_execution_bindings=AsyncMock(side_effect=projected_bindings),
|
||||
get_execution_binding=AsyncMock(side_effect=get_execution_binding),
|
||||
),
|
||||
persistence_mgr=SimpleNamespace(tenant_uow=None),
|
||||
logger=Mock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector._load_workspace_settings = AsyncMock(side_effect=lambda context: settings[context.workspace_uuid])
|
||||
return connector
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
binding_b = execution_binding('workspace-b')
|
||||
setting_a = plugin_setting('01', 'a' * 64)
|
||||
setting_b = plugin_setting('02', 'b' * 64)
|
||||
connector = shared_connector(
|
||||
[[binding_a, binding_b], [binding_a]],
|
||||
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
|
||||
)
|
||||
|
||||
first_handler = runtime_handler()
|
||||
connector.handler = first_handler
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
first_desired = first_handler.reconcile_plugin_installations.await_args.args[0]
|
||||
assert {state.binding.workspace_uuid for state in first_desired} == {'workspace-a', 'workspace-b'}
|
||||
|
||||
second_handler = runtime_handler()
|
||||
connector.handler = second_handler
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
second_desired = second_handler.reconcile_plugin_installations.await_args.args[0]
|
||||
assert [state.binding.workspace_uuid for state in second_desired] == ['workspace-a']
|
||||
second_handler.unregister_installation_binding.assert_called_once_with(first_desired[1].binding)
|
||||
assert set(connector._workspace_installations) == {'workspace-a'}
|
||||
assert set(connector._known_desired_states) == {setting_a.installation_uuid}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
|
||||
package = b'local-lbpkg-bytes'
|
||||
digest = hashlib.sha256(package).hexdigest()
|
||||
binding = execution_binding('workspace-a')
|
||||
setting = plugin_setting('01', digest)
|
||||
connector = shared_connector([[binding]], {'workspace-a': [setting]})
|
||||
connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
|
||||
connector._load_artifact_package = AsyncMock(return_value=package)
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
desired = connector.handler.reconcile_plugin_installations.await_args.args[0][0]
|
||||
connector._load_artifact_package.assert_awaited_once()
|
||||
connector.handler.apply_plugin_installation.assert_awaited_once_with(
|
||||
desired.binding,
|
||||
artifact_package=package,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_upgrade_keeps_legacy_data_plugins_when_no_lbpkg_was_backfilled():
|
||||
binding = execution_binding('workspace-a')
|
||||
setting = plugin_setting('01', hashlib.sha256(b'legacy-installation').hexdigest(), durable=False)
|
||||
legacy_plugin = {
|
||||
'debug': False,
|
||||
'manifest': {'manifest': {'metadata': {'author': 'author', 'name': 'plugin-01'}}},
|
||||
'components': [],
|
||||
}
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='oss'),
|
||||
workspace_service=SimpleNamespace(get_local_execution_binding=AsyncMock(return_value=binding)),
|
||||
persistence_mgr=SimpleNamespace(tenant_uow=None),
|
||||
logger=Mock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
|
||||
connector.handler.list_plugins = AsyncMock(return_value=[legacy_plugin])
|
||||
connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'artifact_missing'})
|
||||
connector._load_workspace_settings = AsyncMock(return_value=[setting])
|
||||
connector._load_artifact_package = AsyncMock(return_value=None)
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
plugins = await connector.list_plugins()
|
||||
|
||||
assert plugins == [legacy_plugin]
|
||||
assert connector.handler.list_plugins.await_count >= 2
|
||||
connector._load_artifact_package.assert_awaited_once()
|
||||
assert not hasattr(connector.handler, 'delete_plugin')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_install_persists_verified_package_before_runtime_apply():
|
||||
package = b'local-lbpkg-bytes'
|
||||
digest = hashlib.sha256(package).hexdigest()
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
binding = InstallationBinding(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest=digest,
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
logger=Mock(),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = runtime_handler()
|
||||
connector._current_execution_context = AsyncMock(return_value=execution_context)
|
||||
connector._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
|
||||
connector._store_artifact_package = AsyncMock()
|
||||
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
|
||||
connector._wait_for_installed_plugin_ready = AsyncMock()
|
||||
|
||||
await connector.install_plugin(
|
||||
PluginInstallSource.LOCAL,
|
||||
{'plugin_file': package},
|
||||
)
|
||||
|
||||
connector._store_artifact_package.assert_awaited_once_with(execution_context, digest, package)
|
||||
connector.handler.apply_plugin_installation.assert_awaited_once_with(
|
||||
binding,
|
||||
artifact_package=package,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
|
||||
async def test_artifact_cleanup_is_reference_counted_within_workspace(
|
||||
remaining_references: int,
|
||||
statement_count: int,
|
||||
):
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
)
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
statements = []
|
||||
|
||||
async def execute(statement):
|
||||
statements.append(statement)
|
||||
return SimpleNamespace(scalar_one=lambda: remaining_references)
|
||||
|
||||
await connector._delete_artifact_if_unreferenced(
|
||||
execution_context,
|
||||
'a' * 64,
|
||||
execute=execute,
|
||||
)
|
||||
|
||||
assert len(statements) == statement_count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_store_and_load_accept_connection_scalar_results():
|
||||
package = b'persisted-lbpkg'
|
||||
digest = hashlib.sha256(package).hexdigest()
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
execute_async = AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: package))
|
||||
connector = connection_result_connector(execute_async)
|
||||
|
||||
await connector._store_artifact_package(execution_context, digest, package)
|
||||
loaded = await connector._load_artifact_package(execution_context, digest)
|
||||
|
||||
assert loaded == package
|
||||
assert execute_async.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_settings_accept_connection_mapping_results():
|
||||
created_at = datetime.datetime(2026, 1, 1)
|
||||
row = {
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'plugin_author': 'author',
|
||||
'plugin_name': 'plugin',
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000001',
|
||||
'artifact_digest': 'a' * 64,
|
||||
'runtime_revision': 2,
|
||||
'enabled': True,
|
||||
'priority': 3,
|
||||
'config': {'key': 'value'},
|
||||
'install_source': 'local',
|
||||
'install_info': {'_artifact_storage': 'tenant_binary_storage_v1'},
|
||||
'created_at': created_at,
|
||||
'updated_at': created_at,
|
||||
}
|
||||
mapped_result = SimpleNamespace(mappings=lambda: SimpleNamespace(all=lambda: [row]))
|
||||
connector = connection_result_connector(AsyncMock(return_value=mapped_result))
|
||||
|
||||
settings = await connector._load_workspace_settings(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(settings) == 1
|
||||
assert settings[0].installation_uuid == row['installation_uuid']
|
||||
assert settings[0].runtime_revision == 2
|
||||
assert settings[0].created_at == created_at
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installation_update_accepts_connection_row_result():
|
||||
old_digest = 'a' * 64
|
||||
new_digest = 'b' * 64
|
||||
existing = SimpleNamespace(
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=4,
|
||||
artifact_digest=old_digest,
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
|
||||
)
|
||||
execute_async = AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(first=lambda: existing),
|
||||
SimpleNamespace(rowcount=1),
|
||||
]
|
||||
)
|
||||
connector = connection_result_connector(execute_async)
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
binding, previous_digest, previous_was_durable = await connector._persist_installation_package(
|
||||
execution_context,
|
||||
plugin_author='author',
|
||||
plugin_name='plugin',
|
||||
install_source=PluginInstallSource.LOCAL,
|
||||
install_info={},
|
||||
artifact_digest=new_digest,
|
||||
)
|
||||
|
||||
assert binding.installation_uuid == existing.installation_uuid
|
||||
assert binding.runtime_revision == 5
|
||||
assert binding.artifact_digest == new_digest
|
||||
assert previous_digest == old_digest
|
||||
assert previous_was_durable is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_dependency_failure_raises_stable_observable_error():
|
||||
binding = execution_binding('workspace-a')
|
||||
setting = plugin_setting('01', 'a' * 64)
|
||||
connector = shared_connector([[binding]], {'workspace-a': [setting]})
|
||||
connector.handler = runtime_handler()
|
||||
desired = (
|
||||
await connector._load_workspace_desired_states(
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
)
|
||||
)[0]
|
||||
connector.handler.apply_plugin_installation.return_value = {
|
||||
'installation_uuid': setting.installation_uuid,
|
||||
'state': 'failed',
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
|
||||
with pytest.raises(PluginInstallationFailedError) as exc_info:
|
||||
await connector._apply_desired_state(desired)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.installation_uuid == setting.installation_uuid
|
||||
assert error.error_code == 'dependency_prepare_failed'
|
||||
assert '[dependency_prepare_failed]' in str(error)
|
||||
assert connector._installation_failures[setting.installation_uuid] == {
|
||||
'installation_uuid': setting.installation_uuid,
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
connector.ap.logger.error.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconcile_records_one_failure_without_blocking_other_state():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
binding_b = execution_binding('workspace-b')
|
||||
setting_a = plugin_setting('01', 'a' * 64)
|
||||
setting_b = plugin_setting('02', 'b' * 64)
|
||||
failure = {
|
||||
'installation_uuid': setting_a.installation_uuid,
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
connector = shared_connector(
|
||||
[[binding_a, binding_b]],
|
||||
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
|
||||
)
|
||||
connector.handler = runtime_handler(failed_installations=[failure])
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
desired = connector.handler.reconcile_plugin_installations.await_args.args[0]
|
||||
assert {item.binding.installation_uuid for item in desired} == {
|
||||
setting_a.installation_uuid,
|
||||
setting_b.installation_uuid,
|
||||
}
|
||||
assert connector._installation_failures == {
|
||||
setting_a.installation_uuid: failure,
|
||||
}
|
||||
assert set(connector._known_desired_states) == {
|
||||
setting_a.installation_uuid,
|
||||
setting_b.installation_uuid,
|
||||
}
|
||||
connector.ap.logger.error.assert_called_once_with(
|
||||
'Plugin installation %s failed during reconcile [%s]: %s',
|
||||
setting_a.installation_uuid,
|
||||
'dependency_prepare_failed',
|
||||
'Plugin dependency installer exited with code 1',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_artifact_repair_adds_dependency_failure_and_continues():
|
||||
package_a = b'package-a'
|
||||
package_b = b'package-b'
|
||||
binding = execution_binding('workspace-a')
|
||||
setting_a = plugin_setting('01', hashlib.sha256(package_a).hexdigest())
|
||||
setting_b = plugin_setting('02', hashlib.sha256(package_b).hexdigest())
|
||||
connector = shared_connector(
|
||||
[[binding]],
|
||||
{'workspace-a': [setting_a, setting_b]},
|
||||
)
|
||||
connector.handler = runtime_handler(
|
||||
missing_artifacts=[
|
||||
setting_a.installation_uuid,
|
||||
setting_b.installation_uuid,
|
||||
]
|
||||
)
|
||||
connector._load_artifact_package = AsyncMock(side_effect=[package_a, package_b])
|
||||
connector.handler.apply_plugin_installation.side_effect = [
|
||||
{
|
||||
'installation_uuid': setting_a.installation_uuid,
|
||||
'state': 'failed',
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
},
|
||||
{'installation_uuid': setting_b.installation_uuid, 'state': 'starting'},
|
||||
]
|
||||
|
||||
result = await connector.reconcile_projected_workspaces(
|
||||
[
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert connector.handler.apply_plugin_installation.await_count == 2
|
||||
assert result['failed_installations'] == [
|
||||
{
|
||||
'installation_uuid': setting_a.installation_uuid,
|
||||
'error_code': 'dependency_prepare_failed',
|
||||
'message': 'Plugin dependency installer exited with code 1',
|
||||
}
|
||||
]
|
||||
assert setting_a.installation_uuid in connector._installation_failures
|
||||
assert setting_b.installation_uuid not in connector._installation_failures
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.plugin import connector as connector_module
|
||||
|
||||
from .test_connector_methods import create_mock_connector
|
||||
|
||||
|
||||
TRUSTED_LEGACY_REQUEST = {
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
'asset_url': 'https://github.com/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
|
||||
}
|
||||
|
||||
|
||||
def _patch_client(monkeypatch, handler):
|
||||
real_async_client = httpx.AsyncClient
|
||||
observed: list[dict] = []
|
||||
|
||||
def client_factory(*args, **kwargs):
|
||||
observed.append(dict(kwargs))
|
||||
return real_async_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
follow_redirects=kwargs.get('follow_redirects', False),
|
||||
trust_env=kwargs.get('trust_env', True),
|
||||
timeout=kwargs.get('timeout'),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(connector_module.httpx, 'AsyncClient', client_factory)
|
||||
return observed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'asset_url',
|
||||
[
|
||||
'http://127.0.0.1/internal.lbpkg',
|
||||
'https://169.254.169.254/latest/meta-data',
|
||||
'https://github.com@127.0.0.1/internal.lbpkg',
|
||||
'https://evil.example/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_install_rejects_internal_or_untrusted_asset_url_before_network(
|
||||
monkeypatch,
|
||||
asset_url,
|
||||
):
|
||||
connector = create_mock_connector()
|
||||
monkeypatch.setattr(
|
||||
connector_module.httpx,
|
||||
'AsyncClient',
|
||||
lambda *_args, **_kwargs: pytest.fail('network client must not be created'),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='GitHub release asset URL'):
|
||||
await connector._download_github_package(
|
||||
{**TRUSTED_LEGACY_REQUEST, 'asset_url': asset_url},
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_asset_id_is_resolved_server_side_and_redirect_escape_is_rejected(
|
||||
monkeypatch,
|
||||
):
|
||||
connector = create_mock_connector()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith('/releases/42'):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'id': 42,
|
||||
'tag_name': 'v1.0.0',
|
||||
'assets': [{'id': 99, 'size': 128, 'state': 'uploaded'}],
|
||||
},
|
||||
)
|
||||
if request.url.path.endswith('/releases/assets/99'):
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={'location': 'http://169.254.169.254/latest/meta-data'},
|
||||
)
|
||||
raise AssertionError(f'unexpected request: {request.url}')
|
||||
|
||||
observed = _patch_client(monkeypatch, handler)
|
||||
with pytest.raises(ValueError, match='untrusted host'):
|
||||
await connector._download_github_package(
|
||||
{
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
'release_id': 42,
|
||||
'asset_id': 99,
|
||||
'asset_url': 'https://attacker.invalid/ignored',
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
assert len(observed) == 1
|
||||
assert observed[0]['trust_env'] is False
|
||||
assert observed[0]['follow_redirects'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_download_rejects_oversized_content_length(monkeypatch):
|
||||
connector = create_mock_connector()
|
||||
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, headers={'content-length': '9'}, content=b'')
|
||||
|
||||
_patch_client(monkeypatch, handler)
|
||||
with pytest.raises(ValueError, match='10 MiB download limit'):
|
||||
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_download_counts_chunked_stream_bytes(monkeypatch):
|
||||
connector = create_mock_connector()
|
||||
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
|
||||
|
||||
class ChunkedBody(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield b'1234'
|
||||
yield b'56789'
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, stream=ChunkedBody())
|
||||
|
||||
_patch_client(monkeypatch, handler)
|
||||
with pytest.raises(ValueError, match='10 MiB download limit'):
|
||||
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_asset_id_download_allows_github_object_redirect(monkeypatch):
|
||||
connector = create_mock_connector()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith('/releases/42'):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'id': 42,
|
||||
'tag_name': 'v1.0.0',
|
||||
'assets': [{'id': 99, 'size': 7, 'state': 'uploaded'}],
|
||||
},
|
||||
)
|
||||
if request.url.path.endswith('/releases/assets/99'):
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={
|
||||
'location': 'https://release-assets.githubusercontent.com/github-production-release-asset/demo'
|
||||
},
|
||||
)
|
||||
if request.url.host == 'release-assets.githubusercontent.com':
|
||||
return httpx.Response(200, content=b'package')
|
||||
raise AssertionError(f'unexpected request: {request.url}')
|
||||
|
||||
_patch_client(monkeypatch, handler)
|
||||
package = await connector._download_github_package(
|
||||
{
|
||||
'owner': 'langbot-app',
|
||||
'repo': 'demo-plugin',
|
||||
'release_tag': 'v1.0.0',
|
||||
'release_id': 42,
|
||||
'asset_id': 99,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
assert package == b'package'
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
@@ -35,14 +35,19 @@ def make_handler(app):
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
workspace_context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
workspace_context,
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
installation_binding = InstallationBinding(
|
||||
**workspace_context.model_dump(exclude_none=True),
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
|
||||
runtime_handler.register_installation_binding(
|
||||
installation_binding,
|
||||
plugin_author='test-author',
|
||||
plugin_name='test-plugin',
|
||||
)
|
||||
runtime_handler._current_action_context.set(installation_binding)
|
||||
query_pool = getattr(app, 'query_pool', None)
|
||||
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.storage.mgr import StorageMgr
|
||||
@@ -52,14 +52,19 @@ def make_handler(app, workspace_context: ActionContext | None = None):
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
workspace_context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
workspace_context,
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
installation_binding = InstallationBinding(
|
||||
**workspace_context.model_dump(exclude_none=True),
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
runtime_handler.bind_action_context(workspace_context.for_installation(installation_uuid))
|
||||
runtime_handler.register_installation_binding(
|
||||
installation_binding,
|
||||
plugin_author='test-author',
|
||||
plugin_name='test-plugin',
|
||||
)
|
||||
runtime_handler._current_action_context.set(installation_binding)
|
||||
query_pool = getattr(app, 'query_pool', None)
|
||||
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
|
||||
|
||||
@@ -169,13 +174,10 @@ class TestInitializePluginSettings:
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_setting_when_not_exists(self, app):
|
||||
"""New plugin settings use default enabled, priority and config values."""
|
||||
async def test_rejects_desired_installation_when_setting_not_exists(self, app):
|
||||
"""A desired-state worker cannot create an unowned Core setting row."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
Mock(),
|
||||
]
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
@@ -186,34 +188,23 @@ class TestInitializePluginSettings:
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert insert_params == {
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'local',
|
||||
'install_info': {'path': '/test'},
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'config': {},
|
||||
}
|
||||
assert response.code != 0
|
||||
assert 'Plugin installation setting was not found' in response.message
|
||||
app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherits_values_from_existing_setting(self, app):
|
||||
"""Existing settings are replaced while preserving user-controlled values."""
|
||||
async def test_existing_desired_setting_remains_core_owned(self, app):
|
||||
"""Runtime initialization validates identity without rewriting Core state."""
|
||||
runtime_handler = make_handler(app)
|
||||
existing_setting = SimpleNamespace(
|
||||
enabled=False,
|
||||
priority=5,
|
||||
config={'key': 'value'},
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(existing_setting),
|
||||
Mock(),
|
||||
Mock(),
|
||||
]
|
||||
app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
@@ -225,13 +216,7 @@ class TestInitializePluginSettings:
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert insert_params['enabled'] is False
|
||||
assert insert_params['priority'] == 5
|
||||
assert insert_params['config'] == {'key': 'value'}
|
||||
assert insert_params['install_source'] == 'github'
|
||||
assert insert_params['install_info'] == {'repo': 'author/name'}
|
||||
app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
|
||||
|
||||
class TestSetBinaryStorage:
|
||||
@@ -367,31 +352,18 @@ class TestGetPluginSettings:
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_defaults_when_setting_not_found(self, app):
|
||||
"""Default plugin settings are returned when no persisted row exists."""
|
||||
async def test_rejects_desired_installation_when_setting_not_found(self, app):
|
||||
"""A desired-state worker cannot synthesize settings for a missing row."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'plugin_config': {},
|
||||
'install_source': 'local',
|
||||
'install_info': {},
|
||||
'installation_uuid': runtime_handler.derive_installation_uuid(
|
||||
runtime_handler.require_bound_action_context(),
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
),
|
||||
}
|
||||
with pytest.raises(ValueError, match='Plugin installation setting was not found'):
|
||||
await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
|
||||
{
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_actual_values_when_setting_exists(self, app):
|
||||
@@ -403,6 +375,9 @@ class TestGetPluginSettings:
|
||||
config={'custom': 'config'},
|
||||
install_source='github',
|
||||
install_info={'repo': 'test/repo'},
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(setting)
|
||||
|
||||
@@ -420,11 +395,9 @@ class TestGetPluginSettings:
|
||||
'plugin_config': {'custom': 'config'},
|
||||
'install_source': 'github',
|
||||
'install_info': {'repo': 'test/repo'},
|
||||
'installation_uuid': runtime_handler.derive_installation_uuid(
|
||||
runtime_handler.require_bound_action_context(),
|
||||
'test-author',
|
||||
'test-plugin',
|
||||
),
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000001',
|
||||
'runtime_revision': 1,
|
||||
'artifact_digest': 'a' * 64,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
|
||||
from langbot_plugin.entities.io.resp import ActionResponse
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
|
||||
|
||||
class EmptyResult:
|
||||
@@ -49,6 +52,7 @@ def workspace_context(workspace_uuid: str = 'workspace-a') -> ActionContext:
|
||||
def make_handler(workspace_uuid: str = 'workspace-a'):
|
||||
context = workspace_context(workspace_uuid)
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(mode='cloud'),
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=EmptyResult())),
|
||||
logger=Mock(),
|
||||
workspace_service=SimpleNamespace(
|
||||
@@ -65,14 +69,19 @@ def make_handler(workspace_uuid: str = 'workspace-a'):
|
||||
Mock(),
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
context,
|
||||
)
|
||||
installation_uuid = runtime_handler._remember_installation(
|
||||
context,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
installation_context = InstallationBinding(
|
||||
**context.model_dump(exclude_none=True),
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
return runtime_handler, app, context.for_installation(installation_uuid)
|
||||
runtime_handler.register_installation_binding(
|
||||
installation_context,
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
)
|
||||
return runtime_handler, app, installation_context
|
||||
|
||||
|
||||
async def invoke_with_context(
|
||||
@@ -92,10 +101,98 @@ async def invoke_with_context(
|
||||
async def test_plugin_action_requires_installation_capability():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
|
||||
with pytest.raises(ValueError, match='missing installation_uuid'):
|
||||
with pytest.raises(ValueError, match='trusted Workspace context'):
|
||||
await runtime_handler.actions[PluginToRuntimeAction.GET_LANGBOT_VERSION.value]({})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_action_enters_trusted_workspace_scope():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
scope_events: list[tuple[str, str]] = []
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def tenant_scope(workspace_uuid: str):
|
||||
scope_events.append(('enter', workspace_uuid))
|
||||
try:
|
||||
yield SimpleNamespace()
|
||||
finally:
|
||||
scope_events.append(('exit', workspace_uuid))
|
||||
|
||||
class RecordingPersistenceManager:
|
||||
def __init__(self, execute_async):
|
||||
self.execute_async = execute_async
|
||||
|
||||
def tenant_scope(self, workspace_uuid: str):
|
||||
return tenant_scope(workspace_uuid)
|
||||
|
||||
app.persistence_mgr = RecordingPersistenceManager(app.persistence_mgr.execute_async)
|
||||
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION,
|
||||
{},
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert scope_events == [('enter', 'workspace-a'), ('exit', 'workspace-a')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_llm_provider_does_not_hold_tenant_database_session():
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
observations: list[bool] = []
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
app.persistence_mgr = manager
|
||||
|
||||
async def invoke_llm(**_kwargs):
|
||||
observations.append(manager.current_session() is None)
|
||||
entered.set()
|
||||
await release.wait()
|
||||
observations.append(manager.current_session() is None)
|
||||
return SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'ok'})
|
||||
|
||||
app.model_mgr = SimpleNamespace(
|
||||
get_model_by_uuid=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
model_entity=SimpleNamespace(workspace_uuid='workspace-a'),
|
||||
provider=SimpleNamespace(invoke_llm=invoke_llm),
|
||||
)
|
||||
)
|
||||
)
|
||||
runtime_handler._require_plugin_action_context = AsyncMock(return_value=(installation_context, SimpleNamespace()))
|
||||
runtime_handler._require_active_action_context = AsyncMock()
|
||||
runtime_handler._resource_exists = AsyncMock(return_value=True)
|
||||
|
||||
action = asyncio.create_task(
|
||||
invoke_with_context(
|
||||
runtime_handler,
|
||||
installation_context,
|
||||
PluginToRuntimeAction.INVOKE_LLM,
|
||||
{
|
||||
'llm_model_uuid': 'model-a',
|
||||
'messages': [],
|
||||
},
|
||||
)
|
||||
)
|
||||
try:
|
||||
await entered.wait()
|
||||
assert observations == [True]
|
||||
release.set()
|
||||
response = await action
|
||||
assert response.code == 0
|
||||
assert observations == [True, True]
|
||||
finally:
|
||||
release.set()
|
||||
if not action.done():
|
||||
await action
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_action_rejects_forged_installation_capability():
|
||||
runtime_handler, _app, installation_context = make_handler()
|
||||
@@ -203,14 +300,12 @@ async def test_legacy_query_id_fallback_is_workspace_scoped():
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_connection_rejects_workspace_rebinding():
|
||||
def test_runtime_connection_is_instance_scoped_and_unbound():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
|
||||
with pytest.raises(ValueError, match='another Workspace'):
|
||||
runtime_handler.bind_action_context(workspace_context('workspace-b'))
|
||||
assert runtime_handler.bound_action_context is None
|
||||
|
||||
|
||||
def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace():
|
||||
def test_inbound_tenant_action_requires_complete_installation_envelope():
|
||||
runtime_handler, _app, installation_context = make_handler()
|
||||
|
||||
assert (
|
||||
@@ -220,35 +315,69 @@ def test_inbound_installation_envelope_is_preserved_only_inside_bound_workspace(
|
||||
)
|
||||
== installation_context
|
||||
)
|
||||
with pytest.raises(ValueError, match='does not match connection Workspace'):
|
||||
with pytest.raises(ValueError, match='complete InstallationBinding'):
|
||||
runtime_handler.validate_inbound_action_context(
|
||||
PluginToRuntimeAction.GET_BOTS.value,
|
||||
workspace_context('workspace-b').for_installation('installation-b'),
|
||||
)
|
||||
|
||||
|
||||
def test_installation_uuid_is_stable_and_workspace_specific():
|
||||
context_a = workspace_context('workspace-a')
|
||||
context_b = workspace_context('workspace-b')
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_oss_worker_capability_remains_usable_after_identity_migration():
|
||||
runtime_handler, app, installation_context = make_handler()
|
||||
app.deployment = SimpleNamespace(mode='oss')
|
||||
setting = SimpleNamespace(
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
installation_uuid=installation_context.installation_uuid,
|
||||
runtime_revision=installation_context.runtime_revision,
|
||||
artifact_digest=installation_context.artifact_digest,
|
||||
)
|
||||
result = Mock()
|
||||
result.first.return_value = setting
|
||||
app.persistence_mgr.execute_async.return_value = result
|
||||
legacy_context = workspace_context().for_installation(installation_context.installation_uuid)
|
||||
|
||||
first = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_a,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
assert (
|
||||
runtime_handler.validate_inbound_action_context(
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION.value,
|
||||
legacy_context,
|
||||
)
|
||||
== legacy_context
|
||||
)
|
||||
repeated = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_a,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
)
|
||||
other_workspace = RuntimeConnectionHandler.derive_installation_uuid(
|
||||
context_b,
|
||||
'author-a',
|
||||
'plugin-a',
|
||||
response = await invoke_with_context(
|
||||
runtime_handler,
|
||||
legacy_context,
|
||||
PluginToRuntimeAction.GET_LANGBOT_VERSION,
|
||||
{},
|
||||
)
|
||||
|
||||
assert first == repeated
|
||||
assert first != other_workspace
|
||||
assert response.code == 0
|
||||
|
||||
|
||||
def test_installation_uuid_cannot_move_between_workspaces():
|
||||
runtime_handler, _app, binding = make_handler()
|
||||
moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2})
|
||||
|
||||
with pytest.raises(ValueError, match='cannot move between Workspaces'):
|
||||
runtime_handler.register_installation_binding(
|
||||
moved,
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newer_revision_fences_old_binding():
|
||||
runtime_handler, _app, binding = make_handler()
|
||||
newer = binding.model_copy(update={'runtime_revision': 2, 'artifact_digest': 'b' * 64})
|
||||
runtime_handler.register_installation_binding(
|
||||
newer,
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
)
|
||||
with pytest.raises(ValueError, match='revision or artifact is stale'):
|
||||
await runtime_handler._resolve_installation_identity(binding)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -281,16 +410,27 @@ async def test_plugin_vector_action_forwards_trusted_context_and_logical_collect
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_to_runtime_action_carries_trusted_connector_context():
|
||||
app = SimpleNamespace(logger=Mock())
|
||||
context = workspace_context()
|
||||
connection = RecordingConnection()
|
||||
runtime_handler = RuntimeConnectionHandler(
|
||||
connection,
|
||||
AsyncMock(return_value=True),
|
||||
app,
|
||||
context,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(runtime_handler.set_runtime_config(None))
|
||||
task = asyncio.create_task(
|
||||
runtime_handler.set_runtime_config(
|
||||
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='runtime-a'),
|
||||
worker_policy=PluginWorkerPolicy(
|
||||
max_cpus=1.0,
|
||||
max_memory_mb=256,
|
||||
max_pids=32,
|
||||
max_open_files=64,
|
||||
max_file_size_mb=128,
|
||||
),
|
||||
runtime_profile='oss_dev',
|
||||
cloud_service_url=None,
|
||||
)
|
||||
)
|
||||
for _ in range(10):
|
||||
if connection.sent:
|
||||
break
|
||||
@@ -299,5 +439,8 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
|
||||
runtime_handler.resp_waiters[request['seq_id']].set_result(ActionResponse.success({}))
|
||||
await task
|
||||
|
||||
assert request['data'] == {}
|
||||
assert request['context'] == context.model_dump(exclude_none=True)
|
||||
assert request['data']['runtime_identity'] == {
|
||||
'instance_uuid': 'instance-a',
|
||||
'runtime_id': 'runtime-a',
|
||||
}
|
||||
assert request.get('context') is None
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
"""Test plugin list filtering by component kinds."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
TEST_INSTALLATION_BINDING = InstallationBinding(
|
||||
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
|
||||
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
|
||||
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
|
||||
|
||||
def configure_connector(connector) -> None:
|
||||
connector._execution_context.set(TEST_EXECUTION_CONTEXT)
|
||||
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
|
||||
connector._load_workspace_settings = AsyncMock(return_value=[])
|
||||
connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -17,6 +44,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data with different component kinds
|
||||
mock_plugins = [
|
||||
@@ -90,7 +118,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
@@ -123,6 +151,7 @@ async def test_plugin_list_filter_no_filter():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data with different component kinds
|
||||
mock_plugins = [
|
||||
@@ -157,7 +186,7 @@ async def test_plugin_list_filter_no_filter():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
@@ -184,6 +213,7 @@ async def test_plugin_list_filter_empty_result():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data - only KnowledgeEngine plugins
|
||||
mock_plugins = [
|
||||
@@ -206,7 +236,7 @@ async def test_plugin_list_filter_empty_result():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
@@ -230,6 +260,7 @@ async def test_plugin_list_filter_plugin_without_components():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data - one with components, one without
|
||||
mock_plugins = [
|
||||
@@ -264,7 +295,7 @@ async def test_plugin_list_filter_plugin_without_components():
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
"""Test plugin list sorting functionality."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.context import InstallationBinding
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
TEST_INSTALLATION_BINDING = InstallationBinding(
|
||||
instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
|
||||
workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
|
||||
placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=1,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
|
||||
|
||||
def configure_connector(connector) -> None:
|
||||
connector._execution_context.set(TEST_EXECUTION_CONTEXT)
|
||||
connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
|
||||
connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -18,6 +44,7 @@ async def test_plugin_list_sorting_debug_first():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data with different debug states and timestamps
|
||||
now = datetime.now()
|
||||
@@ -60,9 +87,7 @@ async def test_plugin_list_sorting_debug_first():
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
async def mock_load_workspace_settings(_execution_context):
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
@@ -85,12 +110,9 @@ async def test_plugin_list_sorting_debug_first():
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
return mock_rows
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
@@ -120,6 +142,7 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock plugin data - all non-debug with different installation times
|
||||
now = datetime.now()
|
||||
@@ -162,9 +185,7 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
async def mock_load_workspace_settings(_execution_context):
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
@@ -187,12 +208,9 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
return mock_rows
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
@@ -217,6 +235,7 @@ async def test_plugin_list_empty():
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
configure_connector(connector)
|
||||
|
||||
# Mock empty plugin list
|
||||
connector.handler.list_plugins = AsyncMock(return_value=[])
|
||||
|
||||
@@ -304,6 +304,22 @@ class TestSkillPathHelpers:
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python scripts/run.py')
|
||||
|
||||
def test_wrap_skill_python_env_keeps_state_outside_read_only_source(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
|
||||
|
||||
command = wrap_skill_command_with_python_env(
|
||||
'python scripts/run.py',
|
||||
mount_path='/workspace/.skills/demo',
|
||||
state_path='/workspace/.skill-envs/demo',
|
||||
)
|
||||
|
||||
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in command
|
||||
assert '_LB_META_DIR="/workspace/.skill-envs/demo/.langbot"' in command
|
||||
assert '_LB_TMP_DIR="/workspace/.skill-envs/demo/.tmp"' in command
|
||||
assert '_LB_PIP_CACHE_DIR="/workspace/.skill-envs/demo/.cache/pip"' in command
|
||||
assert 'root = "/workspace/.skills/demo"' in command
|
||||
assert 'pip install "/workspace/.skills/demo"' in command
|
||||
|
||||
|
||||
class TestSkillToolLoader:
|
||||
"""The skill tool surface is now just ``activate`` + ``register_skill``.
|
||||
@@ -497,7 +513,11 @@ class TestNativeToolLoaderSkillPaths:
|
||||
f.write('demo instructions')
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
shares_filesystem_with_box=True,
|
||||
)
|
||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
@@ -511,6 +531,70 @@ class TestNativeToolLoaderSkillPaths:
|
||||
assert result['content'] == 'demo instructions'
|
||||
assert result['truncated'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_runtime_read_never_interprets_package_root_on_core_host(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, 'SKILL.md'), 'w', encoding='utf-8') as file_obj:
|
||||
file_obj.write('core-host-secret')
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=False,
|
||||
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
|
||||
)
|
||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
query = _make_query(
|
||||
query_id='q-external-read',
|
||||
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
|
||||
)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/.skills/demo/SKILL.md'},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == 'runtime-owned-content'
|
||||
assert 'core-host-secret' not in repr(result)
|
||||
ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, 'secret.txt'), 'w', encoding='utf-8') as file_obj:
|
||||
file_obj.write('core-host-secret')
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=False,
|
||||
)
|
||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
query = _make_query(
|
||||
query_id='q-external-no-protocol',
|
||||
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='owned by the Box Runtime'):
|
||||
await loader.invoke_tool(
|
||||
'grep',
|
||||
{
|
||||
'path': '/workspace/.skills/demo',
|
||||
'pattern': 'core-host-secret',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
@@ -542,8 +626,49 @@ class TestNativeToolLoaderSkillPaths:
|
||||
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
|
||||
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
|
||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=False,
|
||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
||||
loader = NativeToolLoader(ap)
|
||||
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
|
||||
register_activated_skill(
|
||||
query,
|
||||
_make_skill_data(
|
||||
name='demo',
|
||||
package_root='/box-runtime/skills/tenants/workspace/demo',
|
||||
python_project=True,
|
||||
),
|
||||
)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'exec',
|
||||
{
|
||||
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||
'workdir': '/workspace/.skills/demo',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
|
||||
wrapped = tool_parameters['command']
|
||||
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
|
||||
assert 'root = "/workspace/.skills/demo"' in wrapped
|
||||
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
|
||||
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_requires_skill_activation(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
@@ -11,6 +12,7 @@ import pytest
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders import native as native_loader
|
||||
from langbot.pkg.provider.tools.toolmgr import ToolManager
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
@@ -52,7 +54,8 @@ class StubLoader:
|
||||
for tool in self._tools
|
||||
]
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
async def has_tool(self, *args) -> bool:
|
||||
name = args[-1]
|
||||
return any(tool.name == name for tool in self._tools)
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query):
|
||||
@@ -129,11 +132,55 @@ async def test_tool_manager_routes_native_tool_calls():
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock())
|
||||
query = SimpleNamespace(
|
||||
_execution_context=_CONTEXT,
|
||||
bot_uuid=None,
|
||||
pipeline_uuid=None,
|
||||
query_uuid=None,
|
||||
)
|
||||
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
|
||||
|
||||
assert result == {'backend': 'fake'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement():
|
||||
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
manager = ToolManager(SimpleNamespace(box_service=box_service))
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
|
||||
catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
|
||||
|
||||
assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool']
|
||||
assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool']
|
||||
assert box_service.is_workspace_sandbox_available.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocation():
|
||||
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
manager = ToolManager(SimpleNamespace(box_service=box_service))
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'unexpected': True})
|
||||
manager.skill_tool_loader = StubLoader([])
|
||||
manager.plugin_tool_loader = StubLoader([])
|
||||
manager.mcp_tool_loader = StubLoader([])
|
||||
query = SimpleNamespace(
|
||||
_execution_context=_CONTEXT,
|
||||
bot_uuid=None,
|
||||
pipeline_uuid=None,
|
||||
query_uuid=None,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match='exec'):
|
||||
await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
|
||||
|
||||
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tool_loader_hides_tools_when_box_unavailable():
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False)))
|
||||
@@ -159,6 +206,26 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
|
||||
assert await loader.has_tool(tool_name) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundary():
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
require_workspace_sandbox=AsyncMock(side_effect=RuntimeError('entitlement expired')),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
query = SimpleNamespace(
|
||||
_execution_context=_CONTEXT,
|
||||
bot_uuid=None,
|
||||
pipeline_uuid=None,
|
||||
query_uuid=None,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match='entitlement expired'):
|
||||
await loader.invoke_tool('read', {'path': '/workspace/private.txt'}, query)
|
||||
|
||||
box_service.require_workspace_sandbox.assert_awaited_once_with(_CONTEXT)
|
||||
|
||||
|
||||
# ── read/write/edit file tool tests ─────────────────────────────
|
||||
|
||||
|
||||
@@ -386,6 +453,103 @@ async def test_path_escape_blocked():
|
||||
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('tool_name', 'parameters'),
|
||||
[
|
||||
('read', {'path': '/workspace/shared/tenant-b-only.txt'}),
|
||||
(
|
||||
'write',
|
||||
{'path': '/workspace/shared/tenant-b-only.txt', 'content': 'overwritten by tenant a'},
|
||||
),
|
||||
(
|
||||
'edit',
|
||||
{
|
||||
'path': '/workspace/shared/tenant-b-only.txt',
|
||||
'old_string': 'tenant-b-secret',
|
||||
'new_string': 'overwritten by tenant a',
|
||||
},
|
||||
),
|
||||
('glob', {'path': '/workspace/shared', 'pattern': '*'}),
|
||||
('grep', {'path': '/workspace/shared', 'pattern': 'tenant-b-secret'}),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor(
|
||||
monkeypatch,
|
||||
tool_name: str,
|
||||
parameters: dict,
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tenant_a = os.path.join(tmpdir, 'tenant-a')
|
||||
tenant_b = os.path.join(tmpdir, 'tenant-b')
|
||||
os.makedirs(os.path.join(tenant_a, 'shared'))
|
||||
os.makedirs(os.path.join(tenant_b, 'shared'))
|
||||
tenant_b_file = os.path.join(tenant_b, 'shared', 'tenant-b-only.txt')
|
||||
with open(os.path.join(tenant_a, 'shared', 'tenant-a-only.txt'), 'w', encoding='utf-8') as file_obj:
|
||||
file_obj.write('tenant-a-content')
|
||||
with open(tenant_b_file, 'w', encoding='utf-8') as file_obj:
|
||||
file_obj.write('tenant-b-secret')
|
||||
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
_tenant_workspace=Mock(return_value=tenant_a),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
original_open_host_root = native_loader._open_host_root
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open_host_root_after_swap(location, *, create):
|
||||
with original_open_host_root(location, create=create) as root_fd:
|
||||
original_ancestor = os.path.join(tenant_a, 'shared-original')
|
||||
os.rename(os.path.join(tenant_a, 'shared'), original_ancestor)
|
||||
os.symlink(os.path.join(tenant_b, 'shared'), os.path.join(tenant_a, 'shared'))
|
||||
yield root_fd
|
||||
|
||||
monkeypatch.setattr(native_loader, '_open_host_root', open_host_root_after_swap)
|
||||
|
||||
try:
|
||||
result = await loader.invoke_tool(tool_name, parameters, _make_query())
|
||||
except ValueError as exc:
|
||||
result = {'ok': False, 'error': str(exc)}
|
||||
|
||||
assert result.get('ok') is False
|
||||
assert 'tenant-b-secret' not in repr(result)
|
||||
assert 'tenant-b-only.txt' not in repr(result)
|
||||
with open(tenant_b_file, encoding='utf-8') as file_obj:
|
||||
assert file_obj.read() == 'tenant-b-secret'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_file_api_falls_back_to_tenant_box_when_openat_is_unavailable(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
_tenant_workspace=Mock(return_value=tmpdir),
|
||||
execute_tool=AsyncMock(
|
||||
return_value={
|
||||
'ok': True,
|
||||
'stdout': '{"ok": true, "content": "box-owned", "truncated": false}',
|
||||
'stderr': '',
|
||||
}
|
||||
),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
monkeypatch.setattr(native_loader, '_SECURE_HOST_FILE_OPS_AVAILABLE', False)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/file.txt'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == 'box-owned'
|
||||
command = box_service.execute_tool.await_args.args[0]['command']
|
||||
assert 'path = "/workspace/file.txt"' in command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_availability_helper_handles_unavailable_and_errors():
|
||||
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import io
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -54,7 +56,7 @@ def _make_app() -> Mock:
|
||||
storage_mgr.storage_provider.delete = AsyncMock()
|
||||
app.storage_mgr = storage_mgr
|
||||
app.persistence_mgr = Mock()
|
||||
app.persistence_mgr.execute_async = AsyncMock()
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
|
||||
app.plugin_connector = Mock()
|
||||
app.plugin_connector.require_workspace_context = AsyncMock(side_effect=lambda context: context)
|
||||
app.workspace_service = SimpleNamespace(
|
||||
@@ -202,6 +204,36 @@ class TestStoreZipFile:
|
||||
|
||||
|
||||
class TestStoreFileTask:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_file_task_opens_uow_before_first_database_helper(self):
|
||||
kb = _make_kb()
|
||||
active_workspace = contextvars.ContextVar('rag_task_workspace', default=None)
|
||||
observed = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def tenant_uow(workspace_uuid):
|
||||
token = active_workspace.set(workspace_uuid)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
active_workspace.reset(token)
|
||||
|
||||
kb.ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||
kb.ap.persistence_mgr.tenant_uow = tenant_uow
|
||||
|
||||
async def assert_execution_context(_context):
|
||||
observed.append(active_workspace.get())
|
||||
|
||||
kb._assert_execution_context = AsyncMock(side_effect=assert_execution_context)
|
||||
kb._set_file_status = AsyncMock(side_effect=[True, True])
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'completed'})
|
||||
object_key = _upload_key('scoped.pdf')
|
||||
file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
|
||||
|
||||
await kb._store_file_task(CONTEXT, file_obj, Mock())
|
||||
|
||||
assert observed[0] == WORKSPACE_A
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_file_task_marks_completed_and_cleans_storage(self):
|
||||
kb = _make_kb()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
@@ -17,6 +18,7 @@ from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceExecuti
|
||||
from langbot.pkg.rag.knowledge.kbmgr import RAGManager
|
||||
from langbot.pkg.rag.service.runtime import RAGRuntimeService
|
||||
from langbot.pkg.vector.mgr import VectorDBManager
|
||||
from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
from langbot.pkg.workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
@@ -42,6 +44,13 @@ class _PersistenceManager:
|
||||
await connection.commit()
|
||||
return result
|
||||
|
||||
@asynccontextmanager
|
||||
async def tenant_uow(self, _workspace_uuid):
|
||||
# This lightweight fixture does not emulate PostgreSQL RLS; production
|
||||
# persistence tests cover the transaction-bound unit of work itself.
|
||||
async with AsyncSession(self.engine, expire_on_commit=False) as session, session.begin():
|
||||
yield SimpleNamespace(session=session)
|
||||
|
||||
@staticmethod
|
||||
def serialize_model(model, row, masked_columns=()):
|
||||
return {
|
||||
@@ -348,3 +357,34 @@ async def test_stale_generation_is_rejected_before_vector_access(tenant_rag):
|
||||
with pytest.raises(Exception, match='generation'):
|
||||
await manager.upsert(stale, 'kb-a', [[0.1]], ['a'])
|
||||
assert database.collections == []
|
||||
|
||||
|
||||
async def test_pgvector_first_write_binds_dimension_and_later_mismatch_fails(tenant_rag):
|
||||
app, engine = tenant_rag
|
||||
manager = VectorDBManager(app)
|
||||
pgvector = object.__new__(PgVectorDatabase)
|
||||
pgvector.allowed_dimensions = frozenset({1, 2})
|
||||
pgvector.add_embeddings = AsyncMock()
|
||||
pgvector.search = AsyncMock(return_value={'ids': [[]], 'distances': [[]], 'metadatas': [[]]})
|
||||
manager.vector_db = pgvector
|
||||
context = _context(WORKSPACE_A)
|
||||
|
||||
await manager.upsert(context, 'kb-a', [[0.1]], ['chunk-a'])
|
||||
scope = pgvector.add_embeddings.await_args.kwargs['scope']
|
||||
assert scope.workspace_uuid == WORKSPACE_A
|
||||
assert scope.knowledge_base_uuid == 'kb-a'
|
||||
assert scope.embedding_dimension == 1
|
||||
|
||||
async with engine.connect() as connection:
|
||||
selected_dimension = await connection.scalar(
|
||||
sqlalchemy.select(KnowledgeBase.embedding_dimension).where(
|
||||
KnowledgeBase.workspace_uuid == WORKSPACE_A,
|
||||
KnowledgeBase.uuid == 'kb-a',
|
||||
)
|
||||
)
|
||||
assert selected_dimension == 1
|
||||
|
||||
with pytest.raises(ValueError, match='dimension is 1, not 2'):
|
||||
await manager.upsert(context, 'kb-a', [[0.1, 0.2]], ['chunk-b'])
|
||||
with pytest.raises(ValueError, match='not enabled'):
|
||||
await manager.search(context, 'kb-a', [0.1, 0.2, 0.3], 3)
|
||||
|
||||
@@ -32,6 +32,12 @@ def create_mock_app():
|
||||
return mock_app
|
||||
|
||||
|
||||
def scalar_result(value=None):
|
||||
"""Return the scalar-only shape used by Connection column selects."""
|
||||
|
||||
return Mock(scalar_one_or_none=Mock(return_value=value))
|
||||
|
||||
|
||||
class TestSurveyManagerInit:
|
||||
"""Tests for SurveyManager initialization."""
|
||||
|
||||
@@ -67,7 +73,7 @@ class TestSurveyManagerInit:
|
||||
"""Test that initialize loads space URL from config."""
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager.initialize()
|
||||
@@ -80,7 +86,7 @@ class TestSurveyManagerInit:
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com/'}}
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager.initialize()
|
||||
@@ -93,7 +99,7 @@ class TestSurveyManagerInit:
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.instance_config.data = {}
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager.initialize()
|
||||
@@ -110,12 +116,7 @@ class TestLoadTriggeredEvents:
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
|
||||
# Mock existing metadata row
|
||||
mock_row = Mock()
|
||||
mock_row.value = json.dumps(['event1', 'event2'])
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=(mock_row,))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result(json.dumps(['event1', 'event2'])))
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager._load_triggered_events()
|
||||
@@ -128,7 +129,7 @@ class TestLoadTriggeredEvents:
|
||||
"""Test that empty set is used when no events stored."""
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
await manager._load_triggered_events()
|
||||
@@ -218,7 +219,7 @@ class TestTriggerEvent:
|
||||
"""Test that new event is added and saved."""
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
|
||||
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
manager._space_url = 'https://space.example.com'
|
||||
@@ -235,7 +236,7 @@ class TestRecordBotResponseSuccess:
|
||||
manager = survey_module.SurveyManager(mock_app)
|
||||
manager._space_url = 'https://space.example.com'
|
||||
# No existing metadata rows: select returns no row
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
|
||||
return manager
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -304,19 +305,13 @@ class TestRecordBotResponseSuccess:
|
||||
survey_module = get_survey_module()
|
||||
mock_app = create_mock_app()
|
||||
|
||||
count_row = Mock()
|
||||
count_row.value = '42'
|
||||
|
||||
def execute_side_effect(stmt):
|
||||
result = Mock()
|
||||
# Both _load_triggered_events and _load_bot_response_count select
|
||||
# from Metadata; return the count row only for the count key.
|
||||
# Metadata.value; return the count only for the count key.
|
||||
stmt_str = str(stmt.compile(compile_kwargs={'literal_binds': True}))
|
||||
if survey_module.BOT_RESPONSE_COUNT_KEY in stmt_str:
|
||||
result.first.return_value = (count_row,)
|
||||
else:
|
||||
result.first.return_value = None
|
||||
return result
|
||||
return scalar_result('42')
|
||||
return scalar_result()
|
||||
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=execute_side_effect)
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import zipfile
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
@@ -115,3 +118,52 @@ class TestRequireBoxForWrite:
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Reading a skill file'):
|
||||
await service.read_skill_file(_CONTEXT, 'x', 'a.txt')
|
||||
|
||||
|
||||
class TestGithubSkillArchiveLimits:
|
||||
@staticmethod
|
||||
def _service() -> SkillService:
|
||||
return SkillService(SimpleNamespace())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_rejects_declared_oversized_archive(self, monkeypatch):
|
||||
import langbot.pkg.api.http.service.skill as skill_module
|
||||
|
||||
real_client = httpx.AsyncClient
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={'content-length': str(10 * 1024 * 1024 + 1)},
|
||||
content=b'',
|
||||
request=request,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
skill_module.httpx,
|
||||
'AsyncClient',
|
||||
lambda **_kwargs: real_client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='compressed size limit'):
|
||||
await self._service()._download_github_asset('https://codeload.github.com/o/r/zip/main')
|
||||
|
||||
def test_copy_rejects_high_compression_ratio_before_extracting(self):
|
||||
source_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(source_buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('repo-main/skill/SKILL.md', b'---\nname: safe\n---\n')
|
||||
archive.writestr('repo-main/skill/bomb.bin', b'0' * (1024 * 1024))
|
||||
|
||||
source_buffer.seek(0)
|
||||
target_buffer = io.BytesIO()
|
||||
with (
|
||||
zipfile.ZipFile(source_buffer, 'r') as source_zip,
|
||||
zipfile.ZipFile(target_buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip,
|
||||
):
|
||||
with pytest.raises(ValueError, match='compression-ratio limit'):
|
||||
self._service()._copy_github_skill_directory_to_zip(
|
||||
source_zip,
|
||||
target_zip,
|
||||
'repo-main/skill',
|
||||
'safe',
|
||||
)
|
||||
|
||||
@@ -138,6 +138,7 @@ class TestVectorDBManagerInitialization:
|
||||
mgr = VectorDBManager(mock_app)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(mgr.initialize())
|
||||
|
||||
mock_valkey_class.assert_called_once_with(mock_app)
|
||||
@@ -207,7 +208,10 @@ class TestVectorDBManagerInitialization:
|
||||
asyncio.get_event_loop().run_until_complete(mgr.initialize())
|
||||
|
||||
mock_pgvector_class.assert_called_once_with(
|
||||
mock_app, connection_string='postgresql://user:pass@host:5432/langbot'
|
||||
mock_app,
|
||||
connection_string='postgresql://user:pass@host:5432/langbot',
|
||||
use_business_database=False,
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||
)
|
||||
|
||||
def test_initialize_pgvector_with_individual_params(self):
|
||||
@@ -238,7 +242,14 @@ class TestVectorDBManagerInitialization:
|
||||
asyncio.get_event_loop().run_until_complete(mgr.initialize())
|
||||
|
||||
mock_pgvector_class.assert_called_once_with(
|
||||
mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret'
|
||||
mock_app,
|
||||
host='db.example.com',
|
||||
port=5433,
|
||||
database='vectordb',
|
||||
user='admin',
|
||||
password='secret',
|
||||
use_business_database=False,
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||
)
|
||||
|
||||
def test_initialize_pgvector_defaults(self):
|
||||
@@ -260,7 +271,42 @@ class TestVectorDBManagerInitialization:
|
||||
asyncio.get_event_loop().run_until_complete(mgr.initialize())
|
||||
|
||||
mock_pgvector_class.assert_called_once_with(
|
||||
mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres'
|
||||
mock_app,
|
||||
host='localhost',
|
||||
port=5432,
|
||||
database='langbot',
|
||||
user='postgres',
|
||||
password='postgres',
|
||||
use_business_database=False,
|
||||
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||
)
|
||||
|
||||
def test_initialize_pgvector_with_shared_business_database(self):
|
||||
vdb_config = {
|
||||
'use': 'pgvector',
|
||||
'pgvector': {
|
||||
'use_business_database': True,
|
||||
'allowed_dimensions': [768, 1536],
|
||||
},
|
||||
}
|
||||
mock_app = self._create_mock_app(vdb_config)
|
||||
mocks = self._make_vector_import_mocks()
|
||||
mock_pgvector_class = MagicMock()
|
||||
mocks['langbot.pkg.vector.vdbs.pgvector_db'].PgVectorDatabase = mock_pgvector_class
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.vector.mgr import VectorDBManager
|
||||
|
||||
mgr = VectorDBManager(mock_app)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(mgr.initialize())
|
||||
|
||||
mock_pgvector_class.assert_called_once_with(
|
||||
mock_app,
|
||||
use_business_database=True,
|
||||
allowed_dimensions=[768, 1536],
|
||||
)
|
||||
|
||||
def test_initialize_unknown_backend_defaults_to_chroma(self):
|
||||
|
||||
Reference in New Issue
Block a user