mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-27 11:56:42 +08:00
merge: integrate master certification and document identity fixes into 4.11
This commit is contained in:
@@ -427,3 +427,27 @@ class TestApplyEnvOverridesToConfig:
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
|
||||
|
||||
|
||||
class TestCertificationKeyRingEnv:
|
||||
def test_applies_string_key_mapping_from_strict_json(self):
|
||||
load_config = get_load_config_module()
|
||||
cfg = load_config._complete_runtime_policy_defaults({})
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{'PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON': '{"ed25519:issuer":"YWJj"}'},
|
||||
clear=True,
|
||||
):
|
||||
result = load_config._apply_certification_key_ring_env(cfg)
|
||||
assert result['plugin']['certification']['trusted_public_keys'] == {'ed25519:issuer': 'YWJj'}
|
||||
|
||||
def test_rejects_malformed_or_non_mapping_key_ring(self):
|
||||
load_config = get_load_config_module()
|
||||
for value in ('not-json', '[]', '{"":"YWJj"}', '{"ed25519:issuer": 1}'):
|
||||
cfg = load_config._complete_runtime_policy_defaults({})
|
||||
with patch.dict(os.environ, {'PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON': value}, clear=True):
|
||||
try:
|
||||
load_config._apply_certification_key_ring_env(cfg)
|
||||
except ValueError:
|
||||
continue
|
||||
raise AssertionError(f'invalid certification key ring was accepted: {value!r}')
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('deployment', 'certificate', 'force', 'expected_disposition', 'expected_code'),
|
||||
[
|
||||
('cloud', ('valid', 'shared-runtime-v1'), False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'),
|
||||
('cloud', ('absent', None), False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED'),
|
||||
('cloud', ('malformed', None), False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'),
|
||||
('cloud', ('invalid', 'shared-runtime-v1'), True, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'),
|
||||
('oss', ('absent', None), False, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_LEGACY_DEDICATED'),
|
||||
('oss', ('valid', 'shared-runtime-v1'), False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'),
|
||||
(
|
||||
'oss',
|
||||
('invalid', 'shared-runtime-v1'),
|
||||
False,
|
||||
'administrator_force_required',
|
||||
'CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED',
|
||||
),
|
||||
('oss', ('invalid', 'shared-runtime-v1'), True, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_FORCED_DEDICATED'),
|
||||
],
|
||||
)
|
||||
def test_admission_policy_enforces_certification_matrix(
|
||||
deployment: str,
|
||||
certificate: tuple[str, str | None],
|
||||
force: bool,
|
||||
expected_disposition: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
from langbot.pkg.plugin.certification import (
|
||||
CertificateFacts,
|
||||
CertificateVerification,
|
||||
PluginCertificationFacts,
|
||||
decide_plugin_admission,
|
||||
)
|
||||
|
||||
verification, runtime_profile = certificate
|
||||
facts = PluginCertificationFacts(
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
artifact_digest='a' * 64,
|
||||
certificate=CertificateFacts(
|
||||
verification=CertificateVerification(verification),
|
||||
runtime_profile=runtime_profile,
|
||||
),
|
||||
)
|
||||
|
||||
decision = decide_plugin_admission(
|
||||
deployment=deployment,
|
||||
facts=facts,
|
||||
administrator_force=force,
|
||||
)
|
||||
|
||||
assert decision.disposition.value == expected_disposition
|
||||
assert decision.code.value == expected_code
|
||||
assert decision.runtime_profile == (
|
||||
'shared-runtime-v1' if expected_disposition == 'shared_eligible' else 'dedicated'
|
||||
)
|
||||
|
||||
|
||||
def test_archive_inspection_preserves_legacy_tuple_and_exposes_certificate_facts() -> None:
|
||||
from langbot.pkg.plugin.archive import (
|
||||
ArchiveCertificateState,
|
||||
inspect_plugin_archive,
|
||||
inspect_plugin_archive_metadata,
|
||||
)
|
||||
|
||||
manifest = {
|
||||
'kind': 'Plugin',
|
||||
'metadata': {'name': 'example'},
|
||||
'certification': {
|
||||
'runtime_profile': 'shared-runtime-v1',
|
||||
'certificate': {'issuer': 'sdk-test', 'signature': 'not-verified-by-core'},
|
||||
},
|
||||
}
|
||||
archive_bytes = _archive_bytes(manifest)
|
||||
|
||||
inspection = inspect_plugin_archive(archive_bytes)
|
||||
|
||||
assert inspection.artifact_digest == hashlib.sha256(archive_bytes).hexdigest()
|
||||
assert inspection.certificate.state is ArchiveCertificateState.DECLARED
|
||||
assert inspection.certificate.runtime_profile == 'shared-runtime-v1'
|
||||
assert inspection.certificate.payload == manifest['certification']['certificate']
|
||||
assert inspect_plugin_archive_metadata(archive_bytes) == (
|
||||
inspection.manifest,
|
||||
inspection.requirements,
|
||||
inspection.names,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('certification', 'expected_state'),
|
||||
[
|
||||
(None, 'absent'),
|
||||
({'runtime_profile': 123, 'certificate': {}}, 'malformed'),
|
||||
({'runtime_profile': 'shared-runtime-v1'}, 'malformed'),
|
||||
],
|
||||
)
|
||||
def test_archive_inspection_reports_nonverifying_certificate_states(
|
||||
certification: object,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
from langbot.pkg.plugin.archive import inspect_plugin_archive
|
||||
|
||||
manifest: dict[str, object] = {'kind': 'Plugin', 'metadata': {'name': 'example'}}
|
||||
if certification is not None:
|
||||
manifest['certification'] = certification
|
||||
|
||||
inspection = inspect_plugin_archive(_archive_bytes(manifest))
|
||||
|
||||
assert inspection.certificate.state.value == expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('verification', 'expected_visibility'),
|
||||
[
|
||||
('valid', 'tenant_scoped'),
|
||||
('absent', 'detailed_process'),
|
||||
('malformed', 'detailed_process'),
|
||||
('invalid', 'detailed_process'),
|
||||
],
|
||||
)
|
||||
def test_log_visibility_policy_only_scopes_valid_shared_certifications(
|
||||
verification: str,
|
||||
expected_visibility: str,
|
||||
) -> None:
|
||||
from langbot.pkg.plugin.certification import (
|
||||
CertificateFacts,
|
||||
CertificateVerification,
|
||||
PluginCertificationFacts,
|
||||
decide_plugin_log_visibility,
|
||||
)
|
||||
|
||||
facts = PluginCertificationFacts(
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
artifact_digest='a' * 64,
|
||||
certificate=CertificateFacts(
|
||||
verification=CertificateVerification(verification),
|
||||
runtime_profile='shared-runtime-v1',
|
||||
),
|
||||
)
|
||||
|
||||
visibility = decide_plugin_log_visibility(facts)
|
||||
|
||||
assert visibility.value == expected_visibility
|
||||
|
||||
|
||||
def _archive_bytes(manifest: dict[str, object]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w') as archive:
|
||||
import yaml
|
||||
|
||||
archive.writestr('manifest.yaml', yaml.safe_dump(manifest))
|
||||
return buffer.getvalue()
|
||||
@@ -40,6 +40,17 @@ def execution_binding(workspace_uuid: str, generation: int = 1) -> SimpleNamespa
|
||||
)
|
||||
|
||||
|
||||
def mock_archive_admission(connector: PluginRuntimeConnector, digest: str) -> None:
|
||||
# These lifecycle tests use opaque package bytes. Real certificate admission
|
||||
# is exercised by integration/plugin/test_certified_plugin_admission.py.
|
||||
connector._admit_plugin_archive = Mock(
|
||||
side_effect=lambda _package, info: (
|
||||
{**info, '_certification': {'normalized_digest': digest}},
|
||||
SimpleNamespace(for_installation=lambda _uuid: SimpleNamespace(artifact_digest=digest)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def plugin_setting(
|
||||
workspace_suffix: str,
|
||||
artifact_digest: str,
|
||||
@@ -275,6 +286,12 @@ async def test_local_install_persists_verified_package_before_runtime_apply():
|
||||
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._admit_plugin_archive = Mock(
|
||||
return_value=(
|
||||
{'_certification': {'normalized_digest': digest}},
|
||||
SimpleNamespace(for_installation=lambda _installation_uuid: SimpleNamespace(artifact_digest=digest)),
|
||||
)
|
||||
)
|
||||
connector._wait_for_installed_plugin_ready = AsyncMock()
|
||||
|
||||
await connector.install_plugin(
|
||||
@@ -351,6 +368,7 @@ async def test_marketplace_upgrade_reports_multistep_progress():
|
||||
observed_actions.append(task_context.current_action)
|
||||
|
||||
connector._download_marketplace_package = AsyncMock(side_effect=download)
|
||||
mock_archive_admission(connector, digest)
|
||||
connector._inspect_plugin_package = Mock(side_effect=inspect)
|
||||
connector._store_artifact_package = AsyncMock(side_effect=store)
|
||||
connector._persist_installation_package = AsyncMock(side_effect=persist)
|
||||
@@ -403,6 +421,7 @@ async def test_workspace_reads_do_not_wait_for_an_installation_apply():
|
||||
connector.handler = runtime_handler()
|
||||
connector._current_execution_context = AsyncMock(return_value=execution_context)
|
||||
connector._validate_execution_context = AsyncMock(return_value=execution_context)
|
||||
mock_archive_admission(connector, digest)
|
||||
connector._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
|
||||
connector._store_artifact_package = AsyncMock()
|
||||
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
|
||||
@@ -471,6 +490,7 @@ async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply
|
||||
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
|
||||
connector._wait_for_installed_plugin_ready = AsyncMock()
|
||||
events: list[str] = []
|
||||
mock_archive_admission(connector, digest)
|
||||
|
||||
async def delete_legacy_plugin(plugin_author: str, plugin_name: str):
|
||||
assert (plugin_author, plugin_name) == ('author', 'plugin')
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.plugin.connector import _select_marketplace_plugin_version
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('requested_version', 'expected'),
|
||||
[
|
||||
(None, '0.1.4'),
|
||||
('0.1.3', '0.1.3'),
|
||||
],
|
||||
)
|
||||
def test_select_marketplace_plugin_version(requested_version, expected):
|
||||
assert (
|
||||
_select_marketplace_plugin_version(
|
||||
[{'version': '0.1.4'}, {'version': '0.1.3'}],
|
||||
requested_version=requested_version,
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='RunnerDemo',
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_select_marketplace_plugin_version_rejects_requested_missing_version():
|
||||
with pytest.raises(ValueError, match='version 0.1.2 is not available'):
|
||||
_select_marketplace_plugin_version(
|
||||
[{'version': '0.1.4'}],
|
||||
requested_version='0.1.2',
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='RunnerDemo',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('versions', [[], [{'unexpected': 'value'}], 'not-a-list'])
|
||||
def test_select_marketplace_plugin_version_rejects_invalid_latest(versions):
|
||||
with pytest.raises(ValueError, match='has no versions'):
|
||||
_select_marketplace_plugin_version(
|
||||
versions,
|
||||
requested_version=None,
|
||||
plugin_author='langbot-team',
|
||||
plugin_name='RunnerDemo',
|
||||
)
|
||||
@@ -90,7 +90,7 @@ class TestStoreFile:
|
||||
|
||||
def create_user_task(coro, **kwargs):
|
||||
coro.close()
|
||||
return SimpleNamespace(id='task-1', kwargs=kwargs)
|
||||
return SimpleNamespace(id='task-1', kwargs=kwargs, task=Mock())
|
||||
|
||||
kb.ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
|
||||
|
||||
@@ -279,7 +279,7 @@ class TestStoreFileTask:
|
||||
|
||||
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'})
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'completed', 'document_id': 'file-uuid'})
|
||||
object_key = _upload_key('scoped.pdf')
|
||||
file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
|
||||
|
||||
@@ -290,7 +290,7 @@ class TestStoreFileTask:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_file_task_marks_completed_and_cleans_storage(self):
|
||||
kb = _make_kb()
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'completed'})
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'completed', 'document_id': 'file-uuid'})
|
||||
object_key = _upload_key('test.pdf')
|
||||
file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
|
||||
task_context = Mock()
|
||||
@@ -306,7 +306,9 @@ class TestStoreFileTask:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_file_task_marks_failed_and_cleans_storage(self):
|
||||
kb = _make_kb()
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'failed', 'error_message': 'parser failed'})
|
||||
kb._ingest_document = AsyncMock(
|
||||
return_value={'status': 'failed', 'error_message': 'parser failed', 'document_id': 'file-uuid'}
|
||||
)
|
||||
object_key = _upload_key('bad.pdf')
|
||||
file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
|
||||
task_context = Mock()
|
||||
|
||||
@@ -288,7 +288,9 @@ async def test_ingestion_payload_uses_host_owned_kb_collection():
|
||||
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',))
|
||||
app.persistence_mgr.execute_async.return_value = _Result(
|
||||
first=SimpleNamespace(uuid='file-a', status='completed', engine_document_id=None)
|
||||
)
|
||||
|
||||
await runtime.delete_file(CONTEXT_A, 'file-a')
|
||||
app.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
|
||||
@@ -404,7 +406,8 @@ class TestRAGManagerCreateKnowledgeBase:
|
||||
)
|
||||
|
||||
assert manager.knowledge_bases == {}
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
# Insert, interrupted-ingestion reconciliation, rollback delete.
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sets_default_retrieval_settings(self):
|
||||
@@ -476,7 +479,9 @@ 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',))
|
||||
app.persistence_mgr.execute_async.return_value = _Result(
|
||||
first=SimpleNamespace(uuid='file-uuid', status='completed', engine_document_id=None)
|
||||
)
|
||||
|
||||
await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).delete_file(
|
||||
CONTEXT_A,
|
||||
@@ -534,7 +539,7 @@ class TestRAGManagerLoadKnowledgeBasesFromDB:
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_startup_reuses_validated_binding(self):
|
||||
async def test_cloud_startup_revalidates_binding_before_recovery_write(self):
|
||||
class TenantUow:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -554,15 +559,13 @@ class TestRAGManagerLoadKnowledgeBasesFromDB:
|
||||
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')
|
||||
)
|
||||
app.workspace_service.get_execution_binding = AsyncMock(return_value=binding)
|
||||
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()
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a', expected_generation=5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_load_error_gracefully(self):
|
||||
|
||||
Reference in New Issue
Block a user