mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(knowledge): accept case-insensitive upload media types * test(knowledge): pin upload media type normalization
532 lines
26 KiB
Python
532 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
|
|
|
|
|
|
@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
|