mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(knowledge): add optional platform connections and hierarchical spaces * fix(knowledge): return actionable errors for unavailable policy storage * perf(knowledge): resolve space policy once per snapshot
741 lines
34 KiB
Python
741 lines
34 KiB
Python
"""Synthetic platform policy, persistence and management composition regressions."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from deerflow_extension_api.auth import ExtensionPrincipal
|
|
|
|
|
|
@pytest.fixture
|
|
def platform(tmp_path, monkeypatch):
|
|
root = Path(__file__).resolve().parents[2]
|
|
for package in ("knowledge-platform", "knowledge-management"):
|
|
monkeypatch.syspath_prepend(str(root / "examples" / f"deerflow-extension-{package}"))
|
|
from deerflow_extension_knowledge_platform.models import Settings, resource_key
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
resource = resource_key("weknora", "local", "kb")
|
|
settings = Settings.model_validate(
|
|
{"enabled": True, "storage_path": str(tmp_path / "platform.db"), "connections": [{"id": "local", "provider": "weknora", "provider_config": {}, "resources": [{"base_id": "kb", "dataset_id": "wk1_fixture"}]}]}
|
|
)
|
|
store = Store(settings)
|
|
return store, settings, resource
|
|
|
|
|
|
ADMIN = ExtensionPrincipal("alice", is_admin=True)
|
|
MEMBER = ExtensionPrincipal("bob")
|
|
|
|
|
|
def document(resource):
|
|
return {
|
|
"connections": {"local": {}},
|
|
"spaces": {
|
|
"org": {"title": "Organization", "resources": [resource], "members": {"alice": "manager", "bob": "viewer"}},
|
|
"division": {"title": "Division", "parent_id": "org", "processing": {"summary_enabled": True}},
|
|
"team": {"title": "Team", "parent_id": "division", "operations": ["capabilities", "status", "manual"], "processing": {"summary_enabled": False}},
|
|
"project": {"title": "Project", "parent_id": "team"},
|
|
},
|
|
}
|
|
|
|
|
|
def test_four_level_inheritance_and_restart(platform):
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
store, settings, resource = platform
|
|
result = store.replace(ADMIN, document(resource), 0)
|
|
assert result["revision"] == 1
|
|
effective = Store(settings).effective(ADMIN, "project")
|
|
assert effective["resources"][0]["dataset_id"] == "wk1_fixture"
|
|
assert effective["resources"][0]["processing"] == {"summary_enabled": False}
|
|
assert set(effective["resources"][0]["operations"]) == {"capabilities", "status", "manual"}
|
|
assert effective["role"] == "manager"
|
|
|
|
|
|
def deep_policy(settings, directory, resources_per_connection=40, connections=5):
|
|
from deerflow_extension_knowledge_platform.models import Settings, resource_key
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
config = settings.model_dump()
|
|
config["storage_path"] = str(directory / "deep.db")
|
|
config["connections"] = [{"id": f"c{i}", "provider": "weknora", "resources": [{"base_id": f"b{j}", "dataset_id": f"wk1_{i}_{j}"} for j in range(resources_per_connection)]} for i in range(connections)]
|
|
store = Store(Settings.model_validate(config))
|
|
spaces = {f"s{i}": {"title": f"Space {i}", "parent_id": f"s{i - 1}"} for i in range(1, 500)}
|
|
spaces["s0"] = {"title": "Root", "resources": list(store.resources), "members": {"alice": "manager", "bob": "viewer"}}
|
|
spaces["s250"]["processing"] = {"chunk_size": 256, "chunk_overlap": 32}
|
|
spaces["s499"].update(processing={"summary_enabled": False}, default_tool=None, tools=[])
|
|
# Resolve leaves before parents to exercise arbitrary policy JSON order.
|
|
policy = {
|
|
"defaults": {"default_tool": "knowledge_search", "processing": {"summary_enabled": True}},
|
|
"connections": {f"c{i}": {"processing": {"graph_enabled": bool(i % 2)}, "default_tool": "knowledge_entries_search" if i == 4 else "knowledge_search"} for i in range(connections)},
|
|
"spaces": dict(reversed(list(spaces.items()))),
|
|
}
|
|
return store, policy, resource_key("weknora", "c4", "b0")
|
|
|
|
|
|
@pytest.mark.parametrize(("connections", "resources_per_connection"), [(5, 1), (5, 40), (20, 200)])
|
|
def test_deep_policy_does_not_reserialize_processing_per_resource_ancestor(platform, tmp_path, monkeypatch, connections, resources_per_connection):
|
|
from deerflow_extension_knowledge_management.contracts import ProcessingOptions
|
|
|
|
_, settings, _ = platform
|
|
store, policy, resource = deep_policy(settings, tmp_path, resources_per_connection, connections)
|
|
original = ProcessingOptions.model_dump
|
|
dumps = 0
|
|
|
|
def counted_dump(self, *args, **kwargs):
|
|
nonlocal dumps
|
|
dumps += 1
|
|
# A depth/resource product used to perform 25M dumps for this valid tree.
|
|
assert dumps <= 2000, "Processing serialization multiplied by resource count and ancestor depth"
|
|
return original(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(ProcessingOptions, "model_dump", counted_dump)
|
|
store.replace(ADMIN, policy, 0)
|
|
effective = store.effective(MEMBER, "s499")
|
|
assert effective["role"] == "viewer"
|
|
assert len(effective["resources"]) == resources_per_connection * connections
|
|
item = next(item for item in effective["resources"] if item["resource_id"] == resource)
|
|
assert item["processing"] == {"summary_enabled": False, "graph_enabled": False, "chunk_size": 256, "chunk_overlap": 32}
|
|
assert item["default_tool"] is None and item["tools"] == []
|
|
assert item["operations"] == ["capabilities", "status"]
|
|
inherited = next(item for item in store.effective(MEMBER, "s498")["resources"] if item["resource_id"] == resource)
|
|
assert inherited["default_tool"] == "knowledge_entries_search"
|
|
|
|
|
|
def test_listing_deep_spaces_does_not_build_processing_or_resource_projections(platform, tmp_path, monkeypatch):
|
|
from deerflow_extension_knowledge_management.contracts import ProcessingOptions
|
|
|
|
_, settings, _ = platform
|
|
store, policy, _ = deep_policy(settings, tmp_path, resources_per_connection=1)
|
|
store.replace(ADMIN, policy, 0)
|
|
|
|
def unexpected(*args, **kwargs):
|
|
pytest.fail("Space listing materialized management processing/resource projections")
|
|
|
|
monkeypatch.setattr(store, "_effective", unexpected)
|
|
monkeypatch.setattr(ProcessingOptions, "model_dump", unexpected)
|
|
result = store.spaces(MEMBER)
|
|
assert result["revision"] == 1
|
|
assert len(result["items"]) == 500
|
|
assert all(set(item) == {"space_id", "title", "role"} and item["role"] == "viewer" for item in result["items"])
|
|
|
|
|
|
@pytest.mark.parametrize("change", ["resource_removed", "tool_revoked", "connection_disabled", "ancestor_disabled", "member_blocked", "ancestor_revoked"])
|
|
def test_listing_keeps_detail_admission_after_policy_or_operator_changes(platform, change):
|
|
from deerflow_extension_knowledge_platform.models import Settings
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
store, settings, resource = platform
|
|
data = document(resource)
|
|
data["defaults"] = {"default_tool": "knowledge_search"}
|
|
data["spaces"]["unrelated"] = {"title": "Unrelated", "resources": [], "members": {"bob": "viewer"}}
|
|
expected = {"org", "division", "team", "project", "unrelated"}
|
|
if change == "connection_disabled":
|
|
data["connections"]["local"]["enabled"] = False
|
|
elif change == "ancestor_disabled":
|
|
data["spaces"]["division"]["enabled"] = False
|
|
expected -= {"division", "team", "project"}
|
|
elif change == "member_blocked":
|
|
data["spaces"]["team"]["members"] = {"bob": "blocked"}
|
|
expected -= {"team", "project"}
|
|
elif change == "ancestor_revoked":
|
|
del data["spaces"]["org"]["members"]["bob"]
|
|
expected = {"unrelated"}
|
|
store.replace(ADMIN, data, 0)
|
|
changed = settings.model_dump()
|
|
if change == "resource_removed":
|
|
changed["connections"][0]["resources"] = [{"base_id": "replacement", "dataset_id": "wk1_replacement"}]
|
|
expected = {"unrelated"}
|
|
elif change == "tool_revoked":
|
|
changed["connections"][0]["resources"][0]["tools"] = []
|
|
expected = {"unrelated"}
|
|
store = Store(Settings.model_validate(changed))
|
|
assert {item["space_id"] for item in store.spaces(MEMBER)["items"]} == expected
|
|
for space_id in data["spaces"]:
|
|
if space_id in expected:
|
|
assert store.effective(MEMBER, space_id)["role"] == "viewer"
|
|
else:
|
|
with pytest.raises(PermissionError):
|
|
store.effective(MEMBER, space_id)
|
|
|
|
|
|
def test_blocked_members_cannot_be_unblocked_below_or_leak_into_siblings(platform):
|
|
store, _, resource = platform
|
|
data = document(resource)
|
|
data["spaces"]["team"]["members"] = {"bob": "blocked"}
|
|
data["spaces"]["sibling"] = {"title": "Sibling", "parent_id": "division"}
|
|
store.replace(ADMIN, data, 0)
|
|
assert {item["space_id"] for item in store.spaces(MEMBER)["items"]} == {"org", "division", "sibling"}
|
|
data["spaces"]["project"]["members"] = {"bob": "viewer"}
|
|
with pytest.raises(ValueError):
|
|
store.replace(ADMIN, data, 1)
|
|
assert store.read(ADMIN)["revision"] == 1
|
|
|
|
|
|
def test_members_cannot_edit_or_escalate(platform):
|
|
store, _, resource = platform
|
|
store.replace(ADMIN, document(resource), 0)
|
|
with pytest.raises(PermissionError):
|
|
store.replace(MEMBER, document(resource), 1)
|
|
effective = store.effective(MEMBER, "project")
|
|
assert set(effective["resources"][0]["operations"]) == {"capabilities", "status"}
|
|
with pytest.raises(PermissionError):
|
|
store.effective(ExtensionPrincipal("outsider", is_admin=True), "project")
|
|
|
|
|
|
@pytest.mark.parametrize("change", ["cycle", "missing_parent", "resource_widening", "operation_widening", "role_widening", "unknown_connection"])
|
|
def test_invalid_graph_rolls_back(platform, change):
|
|
store, _, resource = platform
|
|
data = document(resource)
|
|
store.replace(ADMIN, data, 0)
|
|
if change == "cycle":
|
|
data["spaces"]["org"]["parent_id"] = "project"
|
|
elif change == "missing_parent":
|
|
data["spaces"]["team"]["parent_id"] = "missing"
|
|
elif change == "resource_widening":
|
|
data["spaces"]["team"]["resources"] = ["unmapped"]
|
|
elif change == "operation_widening":
|
|
data["spaces"]["project"]["operations"] = ["upload"]
|
|
elif change == "role_widening":
|
|
data["spaces"]["project"]["members"] = {"bob": "manager"}
|
|
else:
|
|
data["connections"]["evil"] = {}
|
|
with pytest.raises(ValueError):
|
|
store.replace(ADMIN, data, 1)
|
|
assert store.read(ADMIN)["revision"] == 1
|
|
|
|
|
|
def test_ancestor_revocation_and_connection_disable(platform):
|
|
store, _, resource = platform
|
|
data = document(resource)
|
|
store.replace(ADMIN, data, 0)
|
|
del data["spaces"]["org"]["members"]["bob"]
|
|
store.replace(ADMIN, data, 1)
|
|
with pytest.raises(PermissionError):
|
|
store.effective(MEMBER, "project")
|
|
data["connections"]["local"]["enabled"] = False
|
|
store.replace(ADMIN, data, 2)
|
|
assert store.effective(ADMIN, "project")["resources"] == []
|
|
|
|
|
|
def test_sharing_is_explicit_and_same_named_resources_do_not_alias(platform):
|
|
from deerflow_extension_knowledge_platform.models import resource_key
|
|
|
|
store, _, resource = platform
|
|
assert resource != resource_key("weknora", "other", "kb")
|
|
data = document(resource)
|
|
data["spaces"]["other"] = {"title": "Other organization", "resources": [], "members": {"bob": "viewer"}}
|
|
store.replace(ADMIN, data, 0)
|
|
assert store.effective(MEMBER, "other")["resources"] == []
|
|
data["spaces"]["other"]["resources"] = [resource]
|
|
store.replace(ADMIN, data, 1)
|
|
assert store.effective(MEMBER, "other")["resources"][0]["resource_id"] == resource
|
|
|
|
|
|
def test_concurrent_writers_conflict_instead_of_losing_update(platform):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from deerflow_extension_knowledge_platform.store import RevisionConflict
|
|
|
|
store, _, resource = platform
|
|
|
|
def write():
|
|
try:
|
|
store.replace(ADMIN, document(resource), 0)
|
|
return "saved"
|
|
except RevisionConflict:
|
|
return "conflict"
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
assert sorted(pool.map(lambda _: write(), range(2))) == ["conflict", "saved"]
|
|
|
|
|
|
def _process_writer(settings_data, resource):
|
|
from deerflow_extension_knowledge_platform.models import Settings
|
|
from deerflow_extension_knowledge_platform.store import RevisionConflict, Store
|
|
|
|
try:
|
|
Store(Settings.model_validate(settings_data)).replace(ADMIN, document(resource), 0)
|
|
return "saved"
|
|
except RevisionConflict:
|
|
return "conflict"
|
|
|
|
|
|
def test_independent_processes_share_the_sqlite_revision_fence(platform):
|
|
import multiprocessing
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
|
|
_, settings, resource = platform
|
|
with ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context("spawn")) as pool:
|
|
futures = [pool.submit(_process_writer, settings.model_dump(), resource) for _ in range(2)]
|
|
assert sorted(future.result(timeout=30) for future in futures) == ["conflict", "saved"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_space_management_preserves_principal_and_applies_defaults(platform):
|
|
from deerflow_extension_knowledge_management.contracts import Capabilities, IngestRequest, ProcessingResult
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
data = document(resource)
|
|
store.replace(ADMIN, data, 0)
|
|
calls = []
|
|
|
|
class Provider:
|
|
async def capabilities(self, context, connection, base):
|
|
return Capabilities(operations={"capabilities", "manual", "status"}, processing_options={"summary_enabled"}, pipeline={})
|
|
|
|
async def ingest(self, context, connection, base, body):
|
|
calls.append((context.principal, connection, base, body.processing))
|
|
return ProcessingResult(provider="weknora", connection_id="local", dataset_id="wk1_fixture", document_id="doc", state="queued", provider_state="pending", searchable=False)
|
|
|
|
service = Platform(settings, store, {"local": Provider()})
|
|
body = IngestRequest(kind="manual", title="Synthetic", content="Cedar")
|
|
await service.invoke(ADMIN, "project", resource, "manual", body)
|
|
assert calls[0][:3] == (ADMIN, "local", "kb")
|
|
assert calls[0][3].summary_enabled is False
|
|
for principal, operation in [(MEMBER, "manual"), (ExtensionPrincipal("alice", is_admin=True, is_internal=True), "manual"), (ADMIN, "upload")]:
|
|
with pytest.raises(PermissionError):
|
|
await service.invoke(principal, "project", resource, operation, body)
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_policy_denial_before_any_provider_call(platform):
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
store.replace(ADMIN, document(resource), 0)
|
|
|
|
class Provider:
|
|
def __getattr__(self, name):
|
|
pytest.fail("Denied request contacted provider")
|
|
|
|
service = Platform(settings, store, {"local": Provider()})
|
|
with pytest.raises(PermissionError):
|
|
await service.invoke(ADMIN, "project", "unknown-resource", "status", "doc")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_trusted_auth_and_bounded_input(platform):
|
|
import httpx
|
|
from deerflow_extension_knowledge_platform.router import build_router
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
from fastapi import FastAPI
|
|
|
|
store, settings, resource = platform
|
|
app = FastAPI()
|
|
app.include_router(build_router(Platform(settings, store, {})))
|
|
principal = ADMIN
|
|
scopes = []
|
|
app.state.deerflow_extension_principal_resolver = lambda request: principal
|
|
|
|
async def authorize(request, namespace, scope):
|
|
scopes.append((namespace, scope))
|
|
return True
|
|
|
|
app.state.deerflow_extension_plugin_authz_resolver_async = authorize
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
response = await client.put("/api/extensions/knowledge-platform/policy", json={"revision": 0, "policy": document(resource)})
|
|
assert response.status_code == 200
|
|
response = await client.put("/api/extensions/knowledge-platform/policy", json={"revision": 0, "policy": document(resource)})
|
|
assert response.status_code == 409
|
|
principal = MEMBER
|
|
assert (await client.get("/api/extensions/knowledge-platform/spaces/project")).status_code == 200
|
|
assert (await client.get("/api/extensions/knowledge-platform/policy")).status_code == 403
|
|
assert (await client.post(f"/api/extensions/knowledge-platform/spaces/project/resources/{resource}/documents", json={"user_id": "alice"})).status_code == 403
|
|
principal = ADMIN
|
|
assert (await client.put("/api/extensions/knowledge-platform/policy", content=b"x" * (1024 * 1024 + 1))).status_code == 413
|
|
assert (await client.put("/api/extensions/knowledge-platform/policy", json={"revision": 1, "policy": document(resource), "user_id": "alice"})).status_code == 422
|
|
principal = None
|
|
assert (await client.get("/api/extensions/knowledge-platform/spaces")).status_code == 403
|
|
assert ("knowledge-platform", "write") in scopes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method", ["GET", "PUT"])
|
|
async def test_policy_storage_lock_returns_503_without_mutating(platform, monkeypatch, method):
|
|
import sqlite3
|
|
from contextlib import closing
|
|
|
|
import httpx
|
|
from deerflow_extension_knowledge_platform.router import build_router
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
from fastapi import FastAPI
|
|
|
|
store, settings, resource = platform
|
|
app = FastAPI()
|
|
app.include_router(build_router(Platform(settings, store, {})))
|
|
app.state.deerflow_extension_principal_resolver = lambda request: ADMIN
|
|
|
|
async def authorize(*args):
|
|
return True
|
|
|
|
app.state.deerflow_extension_plugin_authz_resolver_async = authorize
|
|
# Exercise the real busy-timeout error without waiting the production ten seconds.
|
|
monkeypatch.setattr(store, "connect", lambda: sqlite3.connect(store.path, timeout=0.01))
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test") as client:
|
|
with closing(sqlite3.connect(store.path)) as owner:
|
|
owner.execute("BEGIN EXCLUSIVE")
|
|
response = await client.request(method, "/api/extensions/knowledge-platform/policy", **({"json": {"revision": 0, "policy": document(resource)}} if method == "PUT" else {}))
|
|
assert response.status_code == 503
|
|
assert response.json() == {"detail": "Policy storage is busy or unavailable; reload policy before retrying"}
|
|
assert str(store.path) not in response.text
|
|
snapshot = await client.get("/api/extensions/knowledge-platform/policy")
|
|
assert snapshot.status_code == 200 and snapshot.json()["revision"] == 0
|
|
saved = await client.put("/api/extensions/knowledge-platform/policy", json={"revision": 0, "policy": document(resource)})
|
|
assert saved.status_code == 200 and saved.json()["revision"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unavailable_policy_database_returns_sanitized_503(platform):
|
|
import httpx
|
|
from deerflow_extension_knowledge_platform.router import build_router
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
from fastapi import FastAPI
|
|
|
|
store, settings, _ = platform
|
|
store.path.write_bytes(b"invalid SQLite database with private diagnostic content")
|
|
app = FastAPI()
|
|
app.include_router(build_router(Platform(settings, store, {})))
|
|
app.state.deerflow_extension_principal_resolver = lambda request: ADMIN
|
|
|
|
async def authorize(*args):
|
|
return True
|
|
|
|
app.state.deerflow_extension_plugin_authz_resolver_async = authorize
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test") as client:
|
|
response = await client.get("/api/extensions/knowledge-platform/policy")
|
|
assert response.status_code == 503
|
|
assert response.json() == {"detail": "Policy storage is busy or unavailable; reload policy before retrying"}
|
|
assert "private diagnostic" not in response.text and str(store.path) not in response.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_database_work_runs_off_loop(platform, monkeypatch):
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, _ = platform
|
|
original = store.read
|
|
|
|
def read(principal):
|
|
with pytest.raises(RuntimeError):
|
|
asyncio.get_running_loop()
|
|
return original(principal)
|
|
|
|
monkeypatch.setattr(store, "read", read)
|
|
assert (await Platform(settings, store, {}).policy(ADMIN))["revision"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revocation_during_capability_lookup_prevents_write(platform):
|
|
from deerflow_extension_knowledge_management.contracts import Capabilities, IngestRequest
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
data = document(resource)
|
|
store.replace(ADMIN, data, 0)
|
|
|
|
class Provider:
|
|
async def capabilities(self, *args):
|
|
data["connections"]["local"]["enabled"] = False
|
|
await asyncio.to_thread(store.replace, ADMIN, data, 1)
|
|
return Capabilities(operations={"manual"}, processing_options={"summary_enabled"}, pipeline={})
|
|
|
|
async def ingest(self, *args):
|
|
pytest.fail("Revoked policy submitted an upstream write")
|
|
|
|
with pytest.raises(PermissionError):
|
|
await Platform(settings, store, {"local": Provider()}).invoke(ADMIN, "project", resource, "manual", IngestRequest(kind="manual", title="Test", content="Test"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_management_adapter_composition(platform, monkeypatch):
|
|
import hashlib
|
|
import json
|
|
|
|
import httpx
|
|
from deerflow_extension_knowledge_management.contracts import IngestRequest
|
|
from deerflow_extension_knowledge_management.weknora import WeKnoraManagement
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
dataset = "wk1_" + hashlib.sha256(json.dumps(["https://weknora.invalid", "1", "kb"], ensure_ascii=False).encode()).hexdigest()
|
|
updated = settings.model_dump()
|
|
updated["connections"][0]["resources"][0]["dataset_id"] = dataset
|
|
from deerflow_extension_knowledge_platform.models import Settings
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
settings = Settings.model_validate(updated)
|
|
store = Store(settings)
|
|
store.replace(ADMIN, document(resource), 0)
|
|
monkeypatch.setenv("PLATFORM_SYNTHETIC_KEY", "synthetic-secret")
|
|
calls, code = [], [200]
|
|
|
|
def handler(request):
|
|
calls.append(request)
|
|
assert request.headers["X-API-Key"] == "synthetic-secret"
|
|
if code[0] != 200:
|
|
return httpx.Response(code[0], text="synthetic-secret")
|
|
if request.url.path == "/api/v1/knowledge-bases/kb":
|
|
data = {"id": "kb", "capabilities": {"wiki": True}, "chunking_config": {"chunk_size": 1024, "chunk_overlap": 128}}
|
|
else:
|
|
data = {"id": "doc", "knowledge_base_id": "kb", "parse_status": "pending", "enable_status": "enabled"}
|
|
return httpx.Response(200, json={"success": True, "data": data})
|
|
|
|
provider = WeKnoraManagement(
|
|
{
|
|
"connections": [
|
|
{
|
|
"id": "local",
|
|
"base_url": "https://weknora.invalid",
|
|
"tenant_id": "1",
|
|
"api_key_env": "PLATFORM_SYNTHETIC_KEY",
|
|
"grants": [{"user_id": "alice", "knowledge_base_ids": ["kb"], "operations": ["capabilities", "manual", "status"]}],
|
|
}
|
|
]
|
|
},
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
service = Platform(settings, store, {"local": provider})
|
|
body = IngestRequest(kind="manual", title="Synthetic", content="Cedar")
|
|
result = await service.invoke(ADMIN, "project", resource, "manual", body)
|
|
assert result.dataset_id == dataset and result.state == "queued"
|
|
assert json.loads(calls[-1].content)["process_config"]["summary_enabled"] is False
|
|
status = await service.invoke(ADMIN, "project", resource, "status", "doc")
|
|
assert status.document_id == "doc"
|
|
code[0] = 403
|
|
before = len(calls)
|
|
with pytest.raises(PermissionError):
|
|
await service.invoke(ADMIN, "project", resource, "manual", body)
|
|
assert len(calls) == before + 1
|
|
# Correct space policy still cannot bypass the existing provider's exact-user grant.
|
|
data = document(resource)
|
|
data["spaces"]["org"]["members"]["unbound"] = "manager"
|
|
store.replace(ADMIN, data, 1)
|
|
before = len(calls)
|
|
with pytest.raises(PermissionError):
|
|
await service.invoke(ExtensionPrincipal("unbound", is_admin=True), "project", resource, "manual", body)
|
|
assert len(calls) == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mismatched_result_identity_is_not_remapped(platform):
|
|
from deerflow_extension_knowledge_management.contracts import ProcessingResult
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
store.replace(ADMIN, document(resource), 0)
|
|
|
|
class Provider:
|
|
async def status(self, *args):
|
|
return ProcessingResult(provider="weknora", connection_id="other", dataset_id="wrong", document_id="doc", state="completed", provider_state="completed", searchable=True)
|
|
|
|
with pytest.raises(RuntimeError, match="identity"):
|
|
await Platform(settings, store, {"local": Provider()}).invoke(ADMIN, "project", resource, "status", "doc")
|
|
|
|
|
|
def test_connections_and_tools_limit_effective_capabilities(platform):
|
|
store, _, resource = platform
|
|
data = document(resource)
|
|
data["connections"]["local"] = {"operations": ["status"], "tools": ["knowledge_search"], "default_tool": "knowledge_search"}
|
|
store.replace(ADMIN, data, 0)
|
|
item = store.effective(ADMIN, "project")["resources"][0]
|
|
assert item["operations"] == ["status"]
|
|
assert item["tools"] == ["knowledge_search"]
|
|
data["spaces"]["project"]["default_tool"] = "entity_relations_search"
|
|
with pytest.raises(ValueError):
|
|
store.replace(ADMIN, data, 1)
|
|
|
|
|
|
def test_platform_defaults_are_inherited_with_a_permission_ceiling(platform):
|
|
store, _, resource = platform
|
|
data = document(resource)
|
|
data["defaults"] = {"operations": ["capabilities", "status", "manual"], "processing": {"graph_enabled": False}, "tools": ["knowledge_search"], "default_tool": "knowledge_search"}
|
|
store.replace(ADMIN, data, 0)
|
|
item = store.effective(ADMIN, "project")["resources"][0]
|
|
assert item["processing"] == {"graph_enabled": False, "summary_enabled": False}
|
|
assert item["tools"] == ["knowledge_search"]
|
|
assert item["default_tool"] == "knowledge_search"
|
|
assert set(item["operations"]) == {"capabilities", "status", "manual"}
|
|
|
|
|
|
def test_child_can_explicitly_clear_inherited_default_tool(platform):
|
|
store, _, resource = platform
|
|
data = document(resource)
|
|
data["defaults"] = {"default_tool": "knowledge_search"}
|
|
data["spaces"]["project"].update(tools=[], default_tool=None)
|
|
store.replace(ADMIN, data, 0)
|
|
item = store.effective(ADMIN, "project")["resources"][0]
|
|
assert item["tools"] == [] and item["default_tool"] is None
|
|
snapshot = store.read(ADMIN)
|
|
store.replace(ADMIN, snapshot["policy"], 1)
|
|
assert store.effective(ADMIN, "team")["resources"][0]["default_tool"] == "knowledge_search"
|
|
assert store.effective(ADMIN, "project")["resources"][0]["default_tool"] is None
|
|
|
|
|
|
def test_operator_resource_removal_can_be_reconciled_without_global_outage(platform):
|
|
from deerflow_extension_knowledge_platform.models import Settings
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
store, settings, resource = platform
|
|
data = document(resource)
|
|
data["spaces"]["unrelated"] = {"title": "Unrelated", "resources": [], "members": {"alice": "manager"}}
|
|
store.replace(ADMIN, data, 0)
|
|
changed = settings.model_dump()
|
|
changed["connections"][0]["resources"] = [{"base_id": "replacement", "dataset_id": "wk1_replacement"}]
|
|
store = Store(Settings.model_validate(changed))
|
|
snapshot = store.read(ADMIN)
|
|
assert snapshot["revision"] == 1
|
|
assert store.effective(ADMIN, "unrelated")["resources"] == []
|
|
with pytest.raises(PermissionError):
|
|
store.effective(ADMIN, "project")
|
|
assert [s["space_id"] for s in store.spaces(ADMIN)["items"]] == ["unrelated"]
|
|
data["spaces"]["org"]["resources"] = []
|
|
assert store.replace(ADMIN, data, 1)["revision"] == 2
|
|
|
|
|
|
def test_operator_tool_revocation_cannot_leave_an_invalid_default(platform):
|
|
from deerflow_extension_knowledge_platform.models import Settings
|
|
from deerflow_extension_knowledge_platform.store import Store
|
|
|
|
store, settings, resource = platform
|
|
data = document(resource)
|
|
data["defaults"] = {"default_tool": "knowledge_search"}
|
|
store.replace(ADMIN, data, 0)
|
|
changed = settings.model_dump()
|
|
changed["connections"][0]["resources"][0]["tools"] = []
|
|
store = Store(Settings.model_validate(changed))
|
|
assert store.read(ADMIN)["revision"] == 1
|
|
with pytest.raises(PermissionError):
|
|
store.effective(ADMIN, "project")
|
|
|
|
|
|
def test_management_namespace_can_be_registered_without_direct_routes(platform):
|
|
from deerflow_extension_knowledge_management import install
|
|
|
|
class Registry:
|
|
plugins, mounted = [], []
|
|
|
|
def plugin(self, contribution):
|
|
self.plugins.append(contribution)
|
|
return True
|
|
|
|
def routers(self, routes):
|
|
self.mounted.extend(routes)
|
|
|
|
registry = Registry()
|
|
install(registry, {"enabled": True, "routes_enabled": False})
|
|
assert len(registry.plugins) == 1
|
|
assert registry.plugins[0].namespace == "knowledge-management"
|
|
assert not registry.mounted
|
|
|
|
|
|
def test_policy_never_serializes_operator_configuration(platform):
|
|
import json
|
|
|
|
store, _, _ = platform
|
|
assert "provider_config" not in json.dumps(store.read(ADMIN))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dual_namespace_authorization_is_required(platform):
|
|
import httpx
|
|
from deerflow_extension_knowledge_platform.router import build_router
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
from fastapi import FastAPI
|
|
|
|
store, settings, resource = platform
|
|
store.replace(ADMIN, document(resource), 0)
|
|
app = FastAPI()
|
|
app.include_router(build_router(Platform(settings, store, {})))
|
|
app.state.deerflow_extension_principal_resolver = lambda request: ADMIN
|
|
|
|
async def authorize(request, namespace, scope):
|
|
return namespace == "knowledge-platform"
|
|
|
|
app.state.deerflow_extension_plugin_authz_resolver_async = authorize
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
response = await client.get(f"/api/extensions/knowledge-platform/spaces/project/resources/{resource}/capabilities")
|
|
assert response.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["upload", "reprocess", "cancel"])
|
|
async def test_remaining_management_operations_preserve_contract(platform, operation):
|
|
from deerflow_extension_knowledge_management.contracts import Capabilities, ProcessingOptions, ProcessingResult
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
data = document(resource)
|
|
del data["spaces"]["team"]["operations"]
|
|
store.replace(ADMIN, data, 0)
|
|
calls = []
|
|
result = ProcessingResult(provider="weknora", connection_id="local", dataset_id="wk1_fixture", document_id="doc", state="unknown", provider_state="new_upstream_state", searchable=False)
|
|
|
|
class Provider:
|
|
async def capabilities(self, *args):
|
|
return Capabilities(operations={"upload", "reprocess", "cancel"}, processing_options={"summary_enabled"}, pipeline={})
|
|
|
|
async def upload(self, context, connection, base, filename, content, options):
|
|
calls.append((filename, content, options.model_dump(exclude_none=True)))
|
|
return result
|
|
|
|
async def process(self, context, connection, base, document, action, options):
|
|
calls.append((document, action, options.model_dump(exclude_none=True)))
|
|
return result
|
|
|
|
value = await Platform(settings, store, {"local": Provider()}).invoke(ADMIN, "project", resource, operation, ProcessingOptions(), document="doc", filename="guide.txt", content=b"synthetic")
|
|
assert value == result # No fabricated success for an unknown upstream state.
|
|
assert calls == ([("guide.txt", b"synthetic", {"summary_enabled": False})] if operation == "upload" else [("doc", operation, {})])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsupported_options_do_not_mutate_or_retry(platform):
|
|
from deerflow_extension_knowledge_management.contracts import Capabilities, IngestRequest
|
|
from deerflow_extension_knowledge_platform.service import Platform
|
|
|
|
store, settings, resource = platform
|
|
store.replace(ADMIN, document(resource), 0)
|
|
calls = []
|
|
|
|
class Provider:
|
|
async def capabilities(self, *args):
|
|
calls.append("capabilities")
|
|
return Capabilities(operations={"manual"}, processing_options=set(), pipeline={})
|
|
|
|
async def ingest(self, *args):
|
|
pytest.fail("Unsupported option submitted a write")
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
await Platform(settings, store, {"local": Provider()}).invoke(ADMIN, "project", resource, "manual", IngestRequest(kind="manual", title="Synthetic", content="Test"))
|
|
assert calls == ["capabilities"]
|
|
|
|
|
|
def test_install_is_opt_in_and_uses_only_operator_factory(platform, monkeypatch):
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow_extension_knowledge_platform import install
|
|
|
|
_, settings, _ = platform
|
|
calls = []
|
|
|
|
class Registry:
|
|
plugins, mounted = [], []
|
|
|
|
def plugin(self, contribution):
|
|
self.plugins.append(contribution)
|
|
return True
|
|
|
|
def routers(self, routes):
|
|
self.mounted.extend(routes)
|
|
|
|
registry = Registry()
|
|
install(registry, {})
|
|
assert not registry.plugins
|
|
monkeypatch.setattr("deerflow_extension_knowledge_platform.import_module", lambda module: SimpleNamespace(WeKnoraManagement=lambda config: calls.append(config) or object()))
|
|
install(registry, settings.model_dump())
|
|
assert len(registry.plugins) == len(registry.mounted) == len(calls) == 1
|
|
assert registry.plugins[0].namespace == "knowledge-platform"
|
|
assert registry.plugins[0].frontend is None
|