mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
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>
743 lines
34 KiB
Python
743 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
|
|
|
|
pytestmark = pytest.mark.bundled_extension
|
|
|
|
|
|
@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
|