Files
RockChinQ e1ac5e0fc8 feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture

* Add OSS and commercial workspace boundaries

* docs: redesign multi-tenant workspace architecture

* feat(tenancy): implement workspace isolation

* docs(tenancy): record verification evidence

* docs(tenancy): revise single-instance SaaS topology

* docs(tenancy): refine architecture options

* docs: finalize cloud v2 multi-tenant decisions

* feat(tenancy): establish cloud isolation foundations

* feat(tenancy): harden shared cloud runtime boundaries

* docs(tenancy): record final isolation verification

* fix(tenancy): close isolation and permission gaps

* docs(tenancy): record final isolation verification

* feat(tenancy): connect cloud workspace control plane

* fix(build): install git for pinned SDK

* docs(cloud): update control plane verification

* chore: update multi-tenant SDK pin

* fix(cloud): skip legacy model sync during startup

* test(cloud): preserve minimal model manager fixtures

* fix(cloud): preserve authenticated account context

* fix(cloud): reuse authenticated account for user info

* feat(cloud): complete Workspace settings navigation

* test(web): cover Workspace dropdown menu

* feat(web): place workspace controls in sidebar

* refactor(web): streamline workspace controls

* style(web): format workspace layout test

* fix(cloud): surface runtime and workspace plan status

* fix(plugin): keep runtime identity stable across restarts

* fix(ui): widen and center workspace switcher

* fix(ui): hide roles from workspace switcher

* fix(ui): align workspace switcher with sidebar entries

* feat(workspace): add in-product collaboration and direct Cloud launch

* style: format collaboration changes

* fix(workspace): bind collaboration APIs to tenant UoW

* fix(cloud): preserve Core-owned collaboration state

* test(cloud): require Space identity for invite registration

* feat(cloud): complete secure invitation experience

* style(web): format invitation flows

* fix(cloud): recover box runtime without unscoped skill reload

* feat(oss): enforce invitation account and owner billing flows

* style: format OSS account service

* test(oss): cover invitation logout handoff

* fix(oss): resolve workspace owner in scoped session

* feat(cloud): harden multi-tenant runtime resources

* fix(cloud): bound runtime restart storms

* fix(cloud): eliminate periodic runtime CPU spikes

* fix(cloud): enforce instance capacity ceilings

* fix(cloud): scope public login capability discovery

* fix(cloud): bound tenant maintenance and monitoring work

* fix(runtime): bound tenant resource amplification

* fix(deps): pin green multi-tenant plugin SDK

* fix(cloud): handle unavailable skill capability

* fix(security): require authentication for image file endpoint (H-2)

- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations

* test: add comprehensive cross-tenant isolation tests

Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide

* fix(security): resolve M-1, M-2, M-3 security findings

M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.

* fix(cloud): unblock tenant CI and enforce knowledge quotas

* fix(tenancy): scope rerank model sync

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-30 21:43:35 +08:00

881 lines
30 KiB
Python

"""Tests for Workspace-scoped RAG manager and runtime knowledge bases."""
from __future__ import annotations
import dataclasses
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence.rag import KnowledgeBase
from langbot.pkg.rag.knowledge.kbmgr import RAGManager, RuntimeKnowledgeBase
from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
from langbot.pkg.workspace.errors import WorkspaceInvariantError, WorkspaceNotFoundError
CONTEXT_A = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=5,
)
CONTEXT_B = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-b',
placement_generation=5,
)
class _Result:
def __init__(self, rows=(), *, first=None):
self.rows = list(rows)
self._first = first
def all(self):
return self.rows
def first(self):
return self._first
def _entity(*, kb_uuid='kb-a', workspace_uuid='workspace-a', plugin_id='author/engine'):
return KnowledgeBase(
uuid=kb_uuid,
workspace_uuid=workspace_uuid,
name='Test KB',
description='description',
knowledge_engine_plugin_id=plugin_id,
collection_id=kb_uuid,
creation_settings={},
retrieval_settings={},
)
def _app():
return SimpleNamespace(
logger=Mock(),
persistence_mgr=SimpleNamespace(
execute_async=AsyncMock(return_value=_Result()),
serialize_model=Mock(
side_effect=lambda _model, row: {
'uuid': row.uuid,
'workspace_uuid': row.workspace_uuid,
'name': row.name,
'description': row.description,
'knowledge_engine_plugin_id': row.knowledge_engine_plugin_id,
'collection_id': row.collection_id,
'creation_settings': row.creation_settings,
'retrieval_settings': row.retrieval_settings,
}
),
),
plugin_connector=SimpleNamespace(
is_enable_plugin=True,
require_workspace_context=AsyncMock(side_effect=lambda context: context),
list_knowledge_engines=AsyncMock(
return_value=[
{
'plugin_id': 'author/engine',
'name': {'en_US': 'Engine'},
'capabilities': ['doc_ingestion'],
}
]
),
rag_on_kb_create=AsyncMock(),
rag_on_kb_delete=AsyncMock(),
call_rag_ingest=AsyncMock(return_value={'status': 'success'}),
call_rag_retrieve=AsyncMock(return_value={'results': []}),
call_rag_delete_document=AsyncMock(return_value=True),
call_parser=AsyncMock(),
),
workspace_service=SimpleNamespace(
get_execution_binding=AsyncMock(
side_effect=lambda workspace_uuid, **_kwargs: SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
placement_generation=5,
)
)
),
storage_mgr=SimpleNamespace(storage_provider=AsyncMock()),
task_mgr=SimpleNamespace(create_user_task=Mock(return_value=SimpleNamespace(id='task-a'))),
)
@pytest.mark.asyncio
async def test_create_binds_workspace_and_uses_tuple_runtime_key():
app = _app()
manager = RAGManager(app)
kb = await manager.create_knowledge_base(
CONTEXT_A,
name='Created',
knowledge_engine_plugin_id='author/engine',
creation_settings={'model': 'embedding-a'},
)
assert kb.workspace_uuid == 'workspace-a'
assert ('workspace-a', kb.uuid) in manager.knowledge_bases
app.plugin_connector.rag_on_kb_create.assert_awaited_once_with(
'author/engine',
kb.uuid,
{'model': 'embedding-a'},
)
@pytest.mark.asyncio
async def test_create_rejects_unknown_engine_and_rolls_back_plugin_failure():
app = _app()
manager = RAGManager(app)
app.plugin_connector.list_knowledge_engines.return_value = []
with pytest.raises(ValueError, match='not found'):
await manager.create_knowledge_base(
CONTEXT_A,
name='Unknown',
knowledge_engine_plugin_id='missing/engine',
creation_settings={},
)
app.plugin_connector.list_knowledge_engines.return_value = [{'plugin_id': 'author/engine'}]
app.plugin_connector.rag_on_kb_create.side_effect = RuntimeError('plugin failed')
with pytest.raises(RuntimeError, match='plugin failed'):
await manager.create_knowledge_base(
CONTEXT_A,
name='Rollback',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
)
assert manager.knowledge_bases == {}
@pytest.mark.asyncio
async def test_runtime_retrieve_carries_context_and_merges_settings_without_mutation():
app = _app()
entity = _entity()
entity.retrieval_settings = {'top_k': 10, 'model': 'default'}
app.plugin_connector.call_rag_retrieve.return_value = {
'results': [
{
'id': 'entry-a',
'content': [{'type': 'text', 'text': 'hello'}],
'metadata': {},
'distance': 0.2,
}
]
}
runtime = RuntimeKnowledgeBase(app, entity, CONTEXT_A)
overrides = {'top_k': 2, 'filters': {'file_id': 'file-a'}}
results = await runtime.retrieve(CONTEXT_A, 'query', settings=overrides)
assert results[0].id == 'entry-a'
assert overrides == {'top_k': 2, 'filters': {'file_id': 'file-a'}}
payload = app.plugin_connector.call_rag_retrieve.await_args.args[1]
assert payload['knowledge_base_id'] == 'kb-a'
assert payload['collection_id'] == 'kb-a'
assert payload['retrieval_settings']['top_k'] == 2
assert payload['filters'] == {'file_id': 'file-a'}
@pytest.mark.asyncio
async def test_runtime_rejects_cross_workspace_and_stale_contexts():
app = _app()
runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
with pytest.raises(WorkspaceNotFoundError):
await runtime.retrieve(CONTEXT_B, 'query')
stale = CONTEXT_A.__class__(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=4,
)
with pytest.raises(WorkspaceNotFoundError):
await runtime.retrieve(stale, 'query')
app.plugin_connector.call_rag_retrieve.assert_not_awaited()
@pytest.mark.asyncio
async def test_runtime_retrieve_fails_before_bound_connector_on_generation_mismatch():
app = _app()
app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
await runtime.retrieve(CONTEXT_A, 'query')
app.plugin_connector.call_rag_retrieve.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('operation', 'connector_method'),
[
('create', 'rag_on_kb_create'),
('ingest', 'call_rag_ingest'),
('delete', 'call_rag_delete_document'),
],
)
async def test_runtime_plugin_mutations_fail_before_mismatched_connector(operation, connector_method):
app = _app()
app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
if operation == 'create':
await runtime._on_kb_create(CONTEXT_A)
elif operation == 'ingest':
await runtime._ingest_document(CONTEXT_A, {'filename': 'document.pdf'}, 'storage/path')
else:
await runtime._delete_document(CONTEXT_A, 'file-a')
getattr(app.plugin_connector, connector_method).assert_not_awaited()
@pytest.mark.asyncio
async def test_create_fails_before_engine_discovery_on_connector_workspace_mismatch():
app = _app()
app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
manager = RAGManager(app)
with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
await manager.create_knowledge_base(
CONTEXT_A,
name='Other workspace',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
)
app.plugin_connector.list_knowledge_engines.assert_not_awaited()
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_ingestion_payload_uses_host_owned_kb_collection():
app = _app()
runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
metadata = {'filename': 'document.pdf'}
await runtime._ingest_document(CONTEXT_A, metadata, 'uploads/document.pdf')
payload = app.plugin_connector.call_rag_ingest.await_args.args[1]
assert payload['knowledge_base_id'] == 'kb-a'
assert payload['collection_id'] == 'kb-a'
assert payload['file_object']['metadata']['knowledge_base_id'] == 'kb-a'
@pytest.mark.asyncio
async def test_delete_file_checks_workspace_and_parent_before_plugin_call():
app = _app()
runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
app.persistence_mgr.execute_async.return_value = _Result(first=('file-a',))
await runtime.delete_file(CONTEXT_A, 'file-a')
app.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
'author/engine',
'file-a',
'kb-a',
)
missing_app = _app()
missing = RuntimeKnowledgeBase(missing_app, _entity(), CONTEXT_A)
with pytest.raises(WorkspaceNotFoundError):
await missing.delete_file(CONTEXT_A, 'file-other')
missing_app.plugin_connector.call_rag_delete_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_manager_get_remove_and_delete_are_workspace_scoped():
app = _app()
manager = RAGManager(app)
runtime = await manager.load_knowledge_base(CONTEXT_A, _entity())
assert await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a') is runtime
assert await manager.get_knowledge_base_by_uuid(CONTEXT_B, 'kb-a') is None
await manager.remove_knowledge_base_from_runtime(CONTEXT_B, 'kb-a')
assert await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a') is runtime
await manager.delete_knowledge_base(CONTEXT_A, 'kb-a')
app.plugin_connector.rag_on_kb_delete.assert_awaited_once()
assert await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a') is None
@pytest.mark.asyncio
async def test_details_queries_require_workspace_and_enrich_engine():
app = _app()
row_a = _entity()
app.persistence_mgr.execute_async.return_value = _Result([row_a], first=row_a)
manager = RAGManager(app)
listed = await manager.get_all_knowledge_base_details(CONTEXT_A)
fetched = await manager.get_knowledge_base_details(CONTEXT_A, 'kb-a')
assert listed[0]['knowledge_engine']['plugin_id'] == 'author/engine'
assert fetched['knowledge_engine']['capabilities'] == ['doc_ingestion']
@pytest.mark.asyncio
async def test_load_dict_filters_computed_fields_and_requires_matching_workspace():
app = _app()
manager = RAGManager(app)
runtime = await manager.load_knowledge_base(
CONTEXT_A,
{
'uuid': 'kb-a',
'workspace_uuid': 'workspace-a',
'name': 'KB',
'description': '',
'knowledge_engine_plugin_id': 'author/engine',
'collection_id': 'kb-a',
'creation_settings': {},
'retrieval_settings': {},
'knowledge_engine': {'computed': True},
},
)
assert runtime.get_uuid() == 'kb-a'
with pytest.raises(WorkspaceNotFoundError):
await manager.load_knowledge_base(CONTEXT_B, _entity())
# Preserve the complete pre-tenancy RAG manager regression matrix. Every
# runtime operation now carries the immutable ExecutionContext, and cache
# assertions use the Workspace + resource tuple key introduced for isolation.
class TestRAGManagerCreateKnowledgeBase:
@pytest.mark.asyncio
async def test_creates_kb_with_valid_engine(self):
app = _app()
manager = RAGManager(app)
kb = await manager.create_knowledge_base(
CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={'model': 'test'},
)
assert kb.name == 'Test KB'
assert kb.workspace_uuid == CONTEXT_A.workspace_uuid
assert kb.knowledge_engine_plugin_id == 'author/engine'
@pytest.mark.asyncio
async def test_raises_when_engine_not_found(self):
app = _app()
app.plugin_connector.list_knowledge_engines.return_value = []
with pytest.raises(ValueError, match='not found'):
await RAGManager(app).create_knowledge_base(
CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='unknown/engine',
creation_settings={},
)
@pytest.mark.asyncio
async def test_rollback_on_plugin_create_failure(self):
app = _app()
app.plugin_connector.rag_on_kb_create.side_effect = RuntimeError('Plugin error')
manager = RAGManager(app)
with pytest.raises(RuntimeError, match='Plugin error'):
await manager.create_knowledge_base(
CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
)
assert manager.knowledge_bases == {}
assert app.persistence_mgr.execute_async.await_count == 2
@pytest.mark.asyncio
async def test_sets_default_retrieval_settings(self):
app = _app()
kb = await RAGManager(app).create_knowledge_base(
CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
retrieval_settings=None,
)
assert kb.retrieval_settings == {}
@pytest.mark.asyncio
async def test_skips_validation_when_plugin_disabled(self):
app = _app()
app.plugin_connector.is_enable_plugin = False
kb = await RAGManager(app).create_knowledge_base(
CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='any/engine',
creation_settings={},
)
assert kb.knowledge_engine_plugin_id == 'any/engine'
app.plugin_connector.list_knowledge_engines.assert_not_awaited()
class TestRuntimeKnowledgeBaseOnKBCreate:
@pytest.mark.asyncio
async def test_calls_plugin_on_create(self):
app = _app()
entity = _entity()
entity.creation_settings = {'model': 'test'}
await RuntimeKnowledgeBase(app, entity, CONTEXT_A)._on_kb_create(CONTEXT_A)
app.plugin_connector.rag_on_kb_create.assert_awaited_once_with(
'author/engine',
entity.uuid,
{'model': 'test'},
)
@pytest.mark.asyncio
async def test_skips_when_no_plugin_id(self):
app = _app()
await RuntimeKnowledgeBase(
app,
_entity(plugin_id=None),
CONTEXT_A,
)._on_kb_create(CONTEXT_A)
app.plugin_connector.rag_on_kb_create.assert_not_awaited()
@pytest.mark.asyncio
async def test_raises_on_plugin_error(self):
app = _app()
app.plugin_connector.rag_on_kb_create.side_effect = RuntimeError('Plugin failed')
with pytest.raises(RuntimeError, match='Plugin failed'):
await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)._on_kb_create(CONTEXT_A)
class TestRuntimeKnowledgeBaseDeleteFile:
@pytest.mark.asyncio
async def test_delete_file_calls_plugin_and_db(self):
app = _app()
app.persistence_mgr.execute_async.return_value = _Result(first=('file-uuid',))
await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).delete_file(
CONTEXT_A,
'file-uuid',
)
app.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
'author/engine',
'file-uuid',
'kb-a',
)
assert app.persistence_mgr.execute_async.await_count == 2
class TestRuntimeKnowledgeBaseIngestDocument:
@pytest.mark.asyncio
async def test_ingest_calls_plugin(self):
app = _app()
result = await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)._ingest_document(
CONTEXT_A,
{'filename': 'test.pdf'},
'storage/path',
)
assert result == {'status': 'success'}
app.plugin_connector.call_rag_ingest.assert_awaited_once()
@pytest.mark.asyncio
async def test_ingest_raises_when_no_plugin_id(self):
app = _app()
with pytest.raises(ValueError, match='Plugin ID required'):
await RuntimeKnowledgeBase(
app,
_entity(plugin_id=None),
CONTEXT_A,
)._ingest_document(CONTEXT_A, {'filename': 'test.pdf'}, 'path')
class TestRAGManagerLoadKnowledgeBasesFromDB:
@pytest.mark.asyncio
async def test_loads_all_kbs_from_db(self):
app = _app()
kb1 = _entity(kb_uuid='kb-1')
kb2 = _entity(kb_uuid='kb-2')
app.persistence_mgr.execute_async.return_value = _Result([kb1, kb2])
manager = RAGManager(app)
await manager.load_knowledge_bases_from_db()
assert set(manager.knowledge_bases) == {
('workspace-a', 'kb-1'),
('workspace-a', 'kb-2'),
}
@pytest.mark.asyncio
async def test_cloud_startup_reuses_validated_binding(self):
class TenantUow:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
app = _app()
binding = WorkspaceExecutionBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=5,
write_fenced=False,
state='active',
)
app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
app.persistence_mgr.tenant_uow = lambda _workspace_uuid: TenantUow()
app.persistence_mgr.execute_async.return_value = _Result([_entity()])
app.workspace_service.list_active_execution_bindings = AsyncMock(return_value=[binding])
app.workspace_service.get_execution_binding = AsyncMock(
side_effect=AssertionError('startup RAG loader repeated a validated binding lookup')
)
manager = RAGManager(app)
await manager.load_knowledge_bases_from_db()
assert set(manager.knowledge_bases) == {('workspace-a', 'kb-a')}
app.workspace_service.get_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_handles_load_error_gracefully(self):
app = _app()
app.persistence_mgr.execute_async.return_value = _Result([_entity()])
app.workspace_service.get_execution_binding.side_effect = RuntimeError('binding unavailable')
manager = RAGManager(app)
await manager.load_knowledge_bases_from_db()
assert manager.knowledge_bases == {}
app.logger.error.assert_called_once()
class TestRuntimeKnowledgeBaseGetters:
def test_get_uuid_returns_entity_uuid(self):
entity = _entity()
assert RuntimeKnowledgeBase(_app(), entity, CONTEXT_A).get_uuid() == entity.uuid
def test_get_name_returns_entity_name(self):
entity = _entity()
assert RuntimeKnowledgeBase(_app(), entity, CONTEXT_A).get_name() == entity.name
def test_get_knowledge_engine_plugin_id_returns_plugin_id(self):
runtime = RuntimeKnowledgeBase(_app(), _entity(), CONTEXT_A)
assert runtime.get_knowledge_engine_plugin_id() == 'author/engine'
def test_get_knowledge_engine_plugin_id_returns_empty_when_none(self):
runtime = RuntimeKnowledgeBase(_app(), _entity(plugin_id=None), CONTEXT_A)
assert runtime.get_knowledge_engine_plugin_id() == ''
class TestRuntimeKnowledgeBaseRetrieve:
@pytest.mark.asyncio
async def test_retrieve_merges_settings(self):
app = _app()
entity = _entity()
entity.retrieval_settings = {'top_k': 10, 'model': 'default'}
app.plugin_connector.call_rag_retrieve.return_value = {
'results': [
{
'id': 'doc1',
'content': [{'type': 'text', 'text': 'test content'}],
'metadata': {},
'distance': 0.1,
}
]
}
results = await RuntimeKnowledgeBase(app, entity, CONTEXT_A).retrieve(
CONTEXT_A,
'query text',
settings={'top_k': 20},
)
assert len(results) == 1
payload = app.plugin_connector.call_rag_retrieve.await_args.args[1]
assert payload['retrieval_settings'] == {'top_k': 20, 'model': 'default'}
@pytest.mark.asyncio
async def test_retrieve_adds_default_top_k(self):
app = _app()
await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).retrieve(
CONTEXT_A,
'query text',
)
payload = app.plugin_connector.call_rag_retrieve.await_args.args[1]
assert payload['retrieval_settings']['top_k'] == 5
@pytest.mark.asyncio
async def test_retrieve_converts_dict_to_entry(self):
app = _app()
app.plugin_connector.call_rag_retrieve.return_value = {
'results': [
{
'id': 'doc1',
'content': [{'type': 'text', 'text': 'test content'}],
'metadata': {'source': 'file.pdf'},
'distance': 0.15,
}
]
}
results = await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).retrieve(
CONTEXT_A,
'query',
)
assert len(results) == 1
assert results[0].id == 'doc1'
assert hasattr(results[0], 'content')
class TestRuntimeKnowledgeBaseDispose:
@pytest.mark.asyncio
async def test_dispose_calls_on_kb_delete(self):
app = _app()
await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).dispose(CONTEXT_A)
app.plugin_connector.rag_on_kb_delete.assert_awaited_once_with(
'author/engine',
'kb-a',
)
@pytest.mark.asyncio
async def test_dispose_skips_when_no_plugin_id(self):
app = _app()
await RuntimeKnowledgeBase(
app,
_entity(plugin_id=None),
CONTEXT_A,
).dispose(CONTEXT_A)
app.plugin_connector.rag_on_kb_delete.assert_not_awaited()
class TestRAGManagerInit:
def test_init_stores_app_reference(self):
app = _app()
assert RAGManager(app).ap is app
def test_init_creates_empty_knowledge_bases_dict(self):
assert RAGManager(_app()).knowledge_bases == {}
def test_generation_advance_prunes_superseded_runtime_knowledge_bases(self):
class NoGlobalItemsScan(dict):
def items(self):
raise AssertionError('generation advance scanned every knowledge runtime')
app = _app()
manager = RAGManager(app)
manager._cache_runtime(
RuntimeKnowledgeBase(
app,
_entity(),
CONTEXT_A,
)
)
manager.knowledge_bases = NoGlobalItemsScan(manager.knowledge_bases)
next_context = dataclasses.replace(CONTEXT_A, placement_generation=6)
manager._observe_execution_context(next_context)
assert manager.knowledge_bases == {}
with pytest.raises(WorkspaceInvariantError, match='rolled back'):
manager._observe_execution_context(CONTEXT_A)
class TestRAGManagerGetKnowledgeBase:
@pytest.mark.asyncio
async def test_get_knowledge_base_by_uuid_returns_runtime_kb(self):
app = _app()
manager = RAGManager(app)
runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
manager.knowledge_bases[('workspace-a', 'kb-a')] = runtime
result = await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a')
assert result is runtime
@pytest.mark.asyncio
async def test_get_knowledge_base_by_uuid_returns_none_when_not_found(self):
assert (
await RAGManager(_app()).get_knowledge_base_by_uuid(
CONTEXT_A,
'nonexistent-uuid',
)
is None
)
@pytest.mark.asyncio
async def test_remove_knowledge_base_from_runtime(self):
app = _app()
manager = RAGManager(app)
manager.knowledge_bases[('workspace-a', 'kb-a')] = RuntimeKnowledgeBase(
app,
_entity(),
CONTEXT_A,
)
await manager.remove_knowledge_base_from_runtime(CONTEXT_A, 'kb-a')
assert ('workspace-a', 'kb-a') not in manager.knowledge_bases
class TestRAGManagerEnrichKB:
def test_enrich_adds_engine_info_from_map(self):
kb_dict = {'knowledge_engine_plugin_id': 'author/engine'}
engine_map = {
'author/engine': {
'plugin_id': 'author/engine',
'name': 'Test Engine',
'capabilities': ['doc_ingestion', 'search'],
}
}
RAGManager(_app())._enrich_kb_dict(kb_dict, engine_map)
assert kb_dict['knowledge_engine']['plugin_id'] == 'author/engine'
assert kb_dict['knowledge_engine']['capabilities'] == ['doc_ingestion', 'search']
def test_enrich_uses_fallback_when_engine_not_in_map(self):
kb_dict = {'knowledge_engine_plugin_id': 'unknown/engine'}
RAGManager(_app())._enrich_kb_dict(kb_dict, {})
assert kb_dict['knowledge_engine']['plugin_id'] == 'unknown/engine'
assert kb_dict['knowledge_engine']['capabilities'] == []
def test_enrich_uses_fallback_when_no_plugin_id(self):
kb_dict = {}
RAGManager(_app())._enrich_kb_dict(kb_dict, {})
assert kb_dict['knowledge_engine']['plugin_id'] is None
assert 'en_US' in kb_dict['knowledge_engine']['name']
def test_enrich_converts_string_name_to_i18n(self):
kb_dict = {'knowledge_engine_plugin_id': 'author/engine'}
engine_map = {
'author/engine': {
'plugin_id': 'author/engine',
'name': 'Simple Name',
'capabilities': [],
}
}
RAGManager(_app())._enrich_kb_dict(kb_dict, engine_map)
assert kb_dict['knowledge_engine']['name'] == {
'en_US': 'Simple Name',
'zh_Hans': 'Simple Name',
}
class TestRAGManagerDeleteKnowledgeBase:
@pytest.mark.asyncio
async def test_delete_removes_from_runtime_and_disposes(self):
app = _app()
manager = RAGManager(app)
manager.knowledge_bases[('workspace-a', 'kb-a')] = RuntimeKnowledgeBase(
app,
_entity(),
CONTEXT_A,
)
await manager.delete_knowledge_base(CONTEXT_A, 'kb-a')
assert ('workspace-a', 'kb-a') not in manager.knowledge_bases
app.plugin_connector.rag_on_kb_delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_logs_warning_when_not_in_runtime(self):
app = _app()
await RAGManager(app).delete_knowledge_base(CONTEXT_A, 'nonexistent-uuid')
app.logger.warning.assert_called_once()
class TestRAGManagerGetAllDetails:
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_kbs(self):
assert await RAGManager(_app()).get_all_knowledge_base_details(CONTEXT_A) == []
@pytest.mark.asyncio
async def test_enriches_each_kb_with_engine_info(self):
app = _app()
app.persistence_mgr.execute_async.return_value = _Result([_entity()])
result = await RAGManager(app).get_all_knowledge_base_details(CONTEXT_A)
assert len(result) == 1
assert result[0]['knowledge_engine']['plugin_id'] == 'author/engine'
class TestRAGManagerGetDetails:
@pytest.mark.asyncio
async def test_returns_none_when_kb_not_found(self):
assert (
await RAGManager(_app()).get_knowledge_base_details(
CONTEXT_A,
'nonexistent',
)
is None
)
@pytest.mark.asyncio
async def test_returns_enriched_kb_dict(self):
app = _app()
app.persistence_mgr.execute_async.return_value = _Result(first=_entity())
result = await RAGManager(app).get_knowledge_base_details(CONTEXT_A, 'kb-a')
assert result is not None
assert result['knowledge_engine']['plugin_id'] == 'author/engine'
class TestRAGManagerLoadKnowledgeBase:
@pytest.mark.asyncio
async def test_loads_kb_entity_into_runtime(self):
manager = RAGManager(_app())
result = await manager.load_knowledge_base(CONTEXT_A, _entity())
assert ('workspace-a', 'kb-a') in manager.knowledge_bases
assert result.get_uuid() == 'kb-a'
@pytest.mark.asyncio
async def test_load_handles_dict_entity(self):
manager = RAGManager(_app())
kb_dict = {
'uuid': 'kb-uuid',
'workspace_uuid': 'workspace-a',
'name': 'Test',
'description': '',
'knowledge_engine_plugin_id': 'author/engine',
'collection_id': 'kb-uuid',
'creation_settings': {},
'retrieval_settings': {},
'knowledge_engine': {'name': 'should_be_filtered'},
}
await manager.load_knowledge_base(CONTEXT_A, kb_dict)
assert ('workspace-a', 'kb-uuid') in manager.knowledge_bases