deer-flow/backend/tests/test_knowledge_management_extension.py
Rei 9cb2bde557
test(ci): run bundled extension behavior suites outside the core shards (#8180)
Bundled examples keep their behavior tests in backend/tests/, so the core
backend workflow ran optional extension behavior on every pull request. Mark
those suites with pytest.mark.bundled_extension, exclude them from the core
shards and from the duration baseline, and run them in a dedicated workflow
through make test-bundled-extensions. Tests of host mechanisms stay in the
core shards even when an example package is the payload.

The marked suites import deerflow.agents, config, runtime, knowledge and
app.gateway, so the workflow triggers for backend/packages/**,
backend/app/gateway/** and the marked test modules; a backend test pins those
paths, the make target and both marker selections, and fails when a marked
module is missing from the path list. Host/extension compatibility checks — a
host API gate and authorization of contributed routes — live in
test_extension_host_contracts.py so they still fail core CI. The committed
duration baseline drops the 482 entries for the excluded suites.

Refs #8173

Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com>
Co-authored-by: willem-bd <143703838+willem-bd@users.noreply.github.com>
Co-authored-by: Eilen6316 <136898293+Eilen6316@users.noreply.github.com>
2026-11-14 09:12:25 +08:00

534 lines
26 KiB
Python

"""Offline management contract and upstream authorization regressions."""
import json
from pathlib import Path
import httpx
import pytest
from deerflow_extension_api.auth import ExtensionPrincipal
pytestmark = pytest.mark.bundled_extension
@pytest.fixture
def management(monkeypatch):
root = Path(__file__).resolve().parents[2]
monkeypatch.syspath_prepend(str(root / "examples/deerflow-extension-knowledge-management"))
from deerflow_extension_knowledge_management.weknora import WeKnoraManagement
monkeypatch.setenv("MANAGEMENT_TEST_KEY", "synthetic-secret")
calls = []
state = {"status": "pending", "base": "kb-1", "code": 200}
def handler(request):
calls.append(request)
assert request.headers["X-API-Key"] == "synthetic-secret"
if state["code"] != 200:
return httpx.Response(state["code"], text="synthetic-secret private error")
if request.url.path == "/api/v1/knowledge-bases/kb-1":
data = {"id": "kb-1", "capabilities": {"wiki": True, "graph": True, "vector": True}, "chunking_config": state.get("chunking_config", {"chunk_size": 1024, "chunk_overlap": 128})}
else:
data = {"id": "doc-1", "knowledge_base_id": state["base"], "parse_status": state["status"], "enable_status": "enabled"}
return httpx.Response(201 if request.url.path.endswith(("/file", "/url")) else 200, json={"success": True, "data": data})
config = {
"connections": [
{
"id": "local",
"base_url": "https://weknora.example",
"tenant_id": "1",
"api_key_env": "MANAGEMENT_TEST_KEY",
"grants": [{"user_id": "alice", "knowledge_base_ids": ["kb-1"], "operations": ["capabilities", "status", "upload", "url", "manual", "reprocess", "cancel"]}],
}
]
}
return WeKnoraManagement(config, transport=httpx.MockTransport(handler)), calls, state
@pytest.mark.asyncio
async def test_manual_ingestion_status_and_retrieval_identity(management):
from deerflow_extension_knowledge_management.contracts import IngestRequest, ManagementContext
provider, calls, state = management
context = ManagementContext(principal=ExtensionPrincipal("alice", is_admin=True))
result = await provider.ingest(context, "local", "kb-1", IngestRequest(kind="manual", title="Guide", content="Cedar evidence"))
assert result.document_id == "doc-1" and result.state == "queued"
assert result.dataset_id.startswith("wk1_") and not result.searchable
assert json.loads(calls[-1].content)["status"] == "publish"
state["status"] = "completed"
result = await provider.status(context, "local", "kb-1", "doc-1")
assert result.state == "completed" and result.searchable
@pytest.mark.asyncio
@pytest.mark.parametrize(
"principal,base", [(ExtensionPrincipal("alice"), "kb-1"), (ExtensionPrincipal("bob", is_admin=True), "kb-1"), (ExtensionPrincipal("alice", is_admin=True), "other"), (ExtensionPrincipal("alice", is_admin=True, is_internal=True), "kb-1")]
)
async def test_denials_make_no_upstream_requests(management, principal, base):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, calls, _ = management
with pytest.raises(PermissionError):
await provider.status(ManagementContext(principal=principal), "local", base, "doc-1")
assert not calls
@pytest.mark.asyncio
async def test_cross_base_document_cannot_be_reprocessed(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext, ProcessingOptions
provider, calls, state = management
state["base"] = "other"
with pytest.raises(PermissionError):
await provider.process(ManagementContext(principal=ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", "doc-1", "reprocess", ProcessingOptions())
assert all(call.method == "GET" for call in calls)
@pytest.mark.asyncio
async def test_upstream_auth_failure_never_retries_or_leaks(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, calls, state = management
state["code"] = 403
with pytest.raises(PermissionError, match="denied") as error:
await provider.status(ManagementContext(principal=ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", "doc-1")
assert len(calls) == 1 and "synthetic-secret" not in str(error.value)
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["file", "url"])
async def test_ingestion_accepts_created_and_preserves_options(management, kind):
from deerflow_extension_knowledge_management.contracts import IngestRequest, ManagementContext, ProcessingOptions
provider, calls, _ = management
context = ManagementContext(ExtensionPrincipal("alice", is_admin=True))
options = ProcessingOptions(chunk_size=512, chunk_overlap=64, graph_enabled=True)
if kind == "file":
result = await provider.upload(context, "local", "kb-1", "guide.txt", b"Cedar evidence", options)
assert b'"chunk_size": 512' in calls[-1].content
assert b"Cedar evidence" in calls[-1].content
else:
result = await provider.ingest(context, "local", "kb-1", IngestRequest(kind="url", url="https://example.com/guide", processing=options))
assert json.loads(calls[-1].content)["process_config"]["chunking_config"] == {"chunk_size": 512, "chunk_overlap": 64}
assert result.state == "queued"
assert calls[-1].url.path.endswith("/" + kind)
@pytest.mark.asyncio
async def test_reprocess_and_cancel_keep_original_processing_policy(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext, ProcessingOptions
provider, calls, _ = management
context = ManagementContext(ExtensionPrincipal("alice", is_admin=True))
for operation, path in [("reprocess", "reparse"), ("cancel", "cancel-parse")]:
await provider.process(context, "local", "kb-1", "doc-1", operation, ProcessingOptions())
assert calls[-1].url.path.endswith("/" + path)
assert calls[-1].content == b""
@pytest.mark.asyncio
async def test_capabilities_are_provider_and_grant_specific(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, _, _ = management
result = await provider.capabilities(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1")
assert result.pipeline["entries"] == "provider_managed"
assert result.pipeline["entity_relations"] == "provider_managed"
assert "upload" in result.operations
assert "delete" not in result.operations
@pytest.mark.asyncio
async def test_unknown_state_is_not_completed(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, _, state = management
state["status"] = "new_upstream_state"
result = await provider.status(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", "doc-1")
assert result.state == "unknown" and not result.searchable
@pytest.mark.parametrize("payload", [{"chunk_size": 200}, {"chunk_overlap": 50}, {"chunk_size": 100, "chunk_overlap": 100}, {"graph_enabled": "true"}, {"parser_api_key": "secret"}, {"wiki_enabled": True}])
def test_processing_rejects_unsupported_or_invalid_options(management, payload):
from deerflow_extension_knowledge_management.contracts import ProcessingOptions
with pytest.raises(ValueError):
ProcessingOptions.model_validate(payload)
@pytest.mark.asyncio
async def test_unsupported_graph_rejected_before_mutation(management):
from deerflow_extension_knowledge_management.contracts import ProcessingOptions
provider, calls, _ = management
with pytest.raises(NotImplementedError):
provider._options(ProcessingOptions(graph_enabled=True), {"capabilities": {"graph": False}})
assert not calls
@pytest.mark.asyncio
@pytest.mark.parametrize("filename,content", [("../guide.txt", b"text"), ("guide\n.txt", b"text"), ("guide.txt", b""), pytest.param("guide.txt", b"x" * (16 * 1024 * 1024 + 1), id="oversized")])
async def test_invalid_upload_never_reaches_provider(management, filename, content):
from deerflow_extension_knowledge_management.contracts import ManagementContext, ProcessingOptions
provider, calls, _ = management
with pytest.raises(ValueError):
await provider.upload(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", filename, content, ProcessingOptions())
assert not calls
@pytest.fixture
def api(management):
from deerflow_extension_api.auth import EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, EXTENSION_PRINCIPAL_RESOLVER_KEY
from deerflow_extension_knowledge_management.router import build_router
from fastapi import FastAPI
provider, calls, state = management
app = FastAPI()
principal = {"value": ExtensionPrincipal("alice", is_admin=True), "allowed": True}
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: principal["value"])
async def authorize(request, namespace, scope):
assert namespace == "knowledge-management"
assert scope == ("read" if request.method == "GET" else "write")
return principal["allowed"]
setattr(app.state, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, authorize)
app.include_router(build_router(provider))
return app, principal, calls, state
API_BASE = "/api/extensions/knowledge-management/connections/local/bases/kb-1"
@pytest.mark.asyncio
async def test_router_enforces_host_policy_and_ignores_no_client_identity(api):
app, principal, calls, _ = api
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
principal["allowed"] = False
response = await client.post(API_BASE + "/documents", json={"kind": "manual", "title": "Guide", "content": "Text"})
assert response.status_code == 403 and not calls
principal["allowed"] = True
response = await client.post(API_BASE + "/documents", json={"kind": "manual", "title": "Guide", "content": "Text", "user_id": "admin"})
assert response.status_code == 422 and not calls
response = await client.post(API_BASE + "/documents", json={"kind": "manual", "title": "Guide", "content": "Text"})
assert response.status_code == 202 and response.json()["state"] == "queued"
@pytest.mark.asyncio
async def test_router_upload_is_bounded_and_errors_are_sanitized(api, monkeypatch):
import deerflow_extension_knowledge_management.router as router
app, _, calls, state = api
monkeypatch.setattr(router, "MAX_UPLOAD_BYTES", 8)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(API_BASE + "/files?filename=guide.txt", content=b"123456789", headers={"content-type": "application/octet-stream"})
assert response.status_code == 413 and not calls
state["code"] = 500
response = await client.get(API_BASE + "/documents/doc-1")
assert response.status_code == 502
assert "synthetic-secret" not in response.text
assert len(calls) == 1
@pytest.mark.parametrize(
"content_type",
[
"Application/Octet-Stream; charset=binary",
"application/octet-stream ; charset=binary",
"APPLICATION/OCTET-STREAM",
],
ids=["mixed-case-with-parameter", "space-before-parameter", "uppercase"],
)
@pytest.mark.asyncio
async def test_router_upload_accepts_normalized_media_type(api, content_type):
app, _, calls, _ = api
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(
API_BASE + "/files?filename=guide.txt",
content=b"knowledge",
headers={"content-type": content_type},
)
assert response.status_code == 202
assert calls[-1].url.path.endswith("/file")
@pytest.mark.asyncio
async def test_router_fails_closed_without_host_resolver(api):
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY
app, _, calls, _ = api
delattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(API_BASE + "/capabilities")
assert response.status_code == 403 and not calls
def test_install_is_opt_in_and_registers_generic_extension_points(management):
from deerflow_extension_knowledge_management import install
class Registry:
plugins = []
routes = []
def plugin(self, value):
self.plugins.append(value)
return True
def routers(self, value):
self.routes.extend(value)
registry = Registry()
install(registry, {})
assert registry.plugins == registry.routes == []
provider, _, _ = management
install(registry, {"enabled": True, "provider_config": provider.settings.model_dump(mode="json")})
assert registry.plugins[0].namespace == "knowledge-management"
assert len(registry.routes) == 1
@pytest.mark.asyncio
async def test_ingested_document_flows_through_existing_scope_and_citations(management, monkeypatch):
from deerflow_extension_knowledge_management.contracts import IngestRequest, ManagementContext
from deerflow.knowledge import KnowledgeRequestContext, format_result, sources_from_messages
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
from deerflow_extension_weknora import WeKnoraProvider
provider, _, state = management
result = await provider.ingest(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", IngestRequest(kind="manual", title="Guide", content="Cedar evidence"))
state["status"] = "completed"
result = await provider.status(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", result.document_id)
assert result.searchable
def read_handler(request):
path = request.url.path
if path == "/api/v1/knowledge-bases":
data = [{"id": "kb-1", "name": "Guide"}]
elif path.endswith("hybrid-search"):
assert json.loads(request.content)["knowledge_ids"] == [result.document_id]
data = [{"knowledge_id": result.document_id, "content": "Cedar evidence", "knowledge_title": "Guide", "score": 0.8}]
else:
data = {"id": result.document_id, "knowledge_base_id": "kb-1", "parse_status": "completed", "enable_status": "enabled", "title": "Guide"}
return httpx.Response(200, json={"success": True, "data": data})
retrieval = WeKnoraProvider({"spaces": [{"base_url": "https://weknora.example", "tenant_id": "1", "api_key_env": "MANAGEMENT_TEST_KEY", "user_ids": ["alice"]}]}, transport=httpx.MockTransport(read_handler))
scope = {"version": 1, "mode": "selected", "dataset_ids": [result.dataset_id], "document_filters": [{"dataset_id": result.dataset_id, "document_ids": [result.document_id]}]}
found = await retrieval.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent", scope))
content, artifact = format_result(found)
replay = json.loads(json.dumps({"type": "tool", "content": content, "artifact": artifact}))
sources = sources_from_messages([replay])
assert len(sources) == 1 and sources[0].provider == "weknora"
assert sources[0].excerpt == "Cedar evidence"
@pytest.mark.asyncio
async def test_generic_router_supports_non_weknora_provider(api):
from deerflow_extension_api.auth import EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, EXTENSION_PRINCIPAL_RESOLVER_KEY
from deerflow_extension_knowledge_management.contracts import Capabilities
from deerflow_extension_knowledge_management.router import build_router
from fastapi import FastAPI
class SearchOnly:
async def capabilities(self, context, connection, base):
assert context.principal.user_id == "alice"
return Capabilities(operations=frozenset({"capabilities"}), processing_options=frozenset(), pipeline={"parse": "unsupported"})
async def ingest(self, *args):
raise NotImplementedError
original, _, _, _ = api
app = FastAPI()
for key in (EXTENSION_PRINCIPAL_RESOLVER_KEY, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY):
setattr(app.state, key, getattr(original.state, key))
app.include_router(build_router(SearchOnly()))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(API_BASE + "/capabilities")
assert response.status_code == 200 and response.json()["operations"] == ["capabilities"]
response = await client.post(API_BASE + "/documents", json={"kind": "manual", "title": "Guide", "content": "Text"})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.parametrize("code,expected", [(302, 502), (401, 403), (403, 403), (404, 404), (409, 409), (500, 502)])
async def test_http_provider_failures_do_not_retry(api, code, expected):
app, _, calls, state = api
state["code"] = code
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(API_BASE + "/documents", json={"kind": "manual", "title": "Guide", "content": "Text"})
assert response.status_code == expected and len(calls) == 1
assert "synthetic-secret" not in response.text
@pytest.mark.asyncio
async def test_json_size_limit_precedes_provider_calls(api):
app, _, calls, _ = api
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(API_BASE + "/documents", content=b" " * (2 * 1024 * 1024 + 1), headers={"content-type": "application/json"})
assert response.status_code == 413 and not calls
@pytest.mark.parametrize(
"payload",
[{"kind": "url", "url": "https://user:password@example.com"}, {"kind": "manual", "title": "Guide", "content": " "}, {"kind": "url", "url": "file:///etc/passwd"}, {"kind": "url", "url": "https://example.com", "content": "unexpected"}],
)
def test_ingest_contract_rejects_unsafe_or_ambiguous_payloads(management, payload):
from deerflow_extension_knowledge_management.contracts import IngestRequest
with pytest.raises(ValueError):
IngestRequest.model_validate(payload)
@pytest.mark.asyncio
async def test_poll_rechecks_grants_and_never_caches_authorization(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, calls, _ = management
await provider.status(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", "doc-1")
count = len(calls)
with pytest.raises(PermissionError):
await provider.status(ManagementContext(ExtensionPrincipal("mallory", is_admin=True)), "local", "kb-1", "doc-1")
assert len(calls) == count
@pytest.mark.asyncio
async def test_missing_credential_never_uses_another_connection(management, monkeypatch):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, calls, _ = management
monkeypatch.delenv("MANAGEMENT_TEST_KEY")
with pytest.raises(RuntimeError, match="credential"):
await provider.status(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", "doc-1")
assert not calls
@pytest.mark.asyncio
async def test_declared_optional_operations_fail_closed(management):
from deerflow_extension_knowledge_management.contracts import ManagementContext, ManagementProvider, ProcessingOptions
class CatalogOnly(ManagementProvider):
pass
with pytest.raises(NotImplementedError):
await CatalogOnly().upload(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "connection", "base", "file.txt", b"text", ProcessingOptions())
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["manual", "url", "upload", "reprocess"])
async def test_parent_child_chunk_overrides_rejected_before_mutation(management, operation):
from deerflow_extension_knowledge_management.contracts import IngestRequest, ManagementContext, ProcessingOptions
provider, calls, state = management
state["chunking_config"] = {"chunk_size": 1024, "chunk_overlap": 128, "enable_parent_child": True, "parent_chunk_size": 4096, "child_chunk_size": 384}
context = ManagementContext(ExtensionPrincipal("alice", is_admin=True))
options = ProcessingOptions(chunk_size=512, chunk_overlap=64)
with pytest.raises(NotImplementedError, match="parent-child"):
if operation == "upload":
await provider.upload(context, "local", "kb-1", "guide.txt", b"text", options)
elif operation == "reprocess":
await provider.process(context, "local", "kb-1", "doc-1", operation, options)
else:
body = {"kind": operation, "title": "Guide", "processing": options}
body.update({"content": "text"} if operation == "manual" else {"url": "https://example.com"})
await provider.ingest(context, "local", "kb-1", IngestRequest(**body))
assert calls and all(call.method == "GET" for call in calls)
assert state["chunking_config"]["parent_chunk_size"] == 4096
def test_parent_child_without_chunk_overrides_preserves_policy(management):
from deerflow_extension_knowledge_management.contracts import ProcessingOptions
provider, _, _ = management
base = {"chunking_config": {"enable_parent_child": True, "parent_chunk_size": 4096, "child_chunk_size": 384}}
assert provider._options(ProcessingOptions(), base) == {}
assert provider._options(ProcessingOptions(summary_enabled=False), base) == {"summary_enabled": False}
def test_weknora_zero_overlap_override_is_not_silently_ignored(management):
from deerflow_extension_knowledge_management.contracts import ProcessingOptions
provider, _, _ = management
with pytest.raises(NotImplementedError):
provider._options(ProcessingOptions(chunk_size=512, chunk_overlap=0), {"chunking_config": {"chunk_overlap": 128}})
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["timeout", "connect", "json", "envelope", "status"])
async def test_provider_failure_diagnostics_are_sanitized(management, caplog, failure):
from deerflow_extension_knowledge_management.contracts import ManagementContext
from deerflow_extension_knowledge_management.router import _invoke
from fastapi import HTTPException
provider, _, _ = management
secret = "synthetic-secret private-payload https://private.example/token"
def fail(request):
if failure == "timeout":
raise httpx.ReadTimeout(secret, request=request)
if failure == "connect":
raise httpx.ConnectError(secret, request=request)
if failure == "json":
return httpx.Response(200, text=secret)
if failure == "envelope":
return httpx.Response(200, json={"error": secret})
return httpx.Response(503, text=secret)
provider.transport = httpx.MockTransport(fail)
with caplog.at_level("WARNING"), pytest.raises(HTTPException) as caught:
await _invoke(provider.status(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1", "doc-1"))
assert caught.value.status_code == 502
expected = {"timeout": "ReadTimeout", "connect": "ConnectError", "json": "JSONDecodeError", "envelope": "invalid_envelope", "status": "status=503"}[failure]
assert expected in caplog.text
assert "RuntimeError" in caplog.text
for text in (caplog.text, str(caught.value.detail)):
assert "synthetic-secret" not in text and "private-payload" not in text and "private.example" not in text
assert all(record.exc_info is None for record in caplog.records)
@pytest.mark.asyncio
async def test_custom_provider_exception_text_is_not_logged(management, caplog):
from deerflow_extension_knowledge_management.router import _invoke
from fastapi import HTTPException
async def fail():
raise RuntimeError("synthetic-secret private document")
with caplog.at_level("WARNING"), pytest.raises(HTTPException):
await _invoke(fail())
assert "RuntimeError" in caplog.text
assert "synthetic-secret" not in caplog.text and "private document" not in caplog.text
assert all(record.exc_info is None for record in caplog.records)
@pytest.mark.asyncio
@pytest.mark.parametrize("chunking,graph,expected", [({"enable_parent_child": True}, False, {"summary_enabled"}), (None, True, {"summary_enabled", "graph_enabled"}), ({}, False, {"summary_enabled", "chunk_size", "chunk_overlap"})])
async def test_capability_options_reflect_base_support(management, chunking, graph, expected):
from deerflow_extension_knowledge_management.contracts import ManagementContext
provider, _, _ = management
provider.transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"success": True, "data": {"id": "kb-1", "chunking_config": chunking, "capabilities": {"graph": graph}}}))
result = await provider.capabilities(ManagementContext(ExtensionPrincipal("alice", is_admin=True)), "local", "kb-1")
assert result.processing_options == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("credential", [None, "", "invalid secret", "非ASCII"])
async def test_unavailable_credential_is_local_service_failure(api, monkeypatch, caplog, credential):
app, _, calls, _ = api
if credential is None:
monkeypatch.delenv("MANAGEMENT_TEST_KEY")
else:
monkeypatch.setenv("MANAGEMENT_TEST_KEY", credential)
with caplog.at_level("WARNING"):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(API_BASE + "/capabilities")
assert response.status_code == 503
assert response.json()["detail"] == "Management credential is not configured"
assert not calls
assert "ManagementCredentialUnavailable" in caplog.text
assert "reconcile" not in response.text
if credential:
assert credential not in caplog.text + response.text