deer-flow/backend/tests/test_weknora_shared_access.py
王一帆 463361c5ba
feat(weknora): allow opt-in shared retrieval for authenticated users (#7944)
* feat(weknora): allow opt-in shared authenticated retrieval

* docs(weknora): clarify shared retrieval identity requirements

* docs(weknora): clarify durable batch authentication limits

* docs(gateway): keep agent guidance within hard size limit

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 17:06:05 +08:00

197 lines
9.0 KiB
Python

"""Dynamic users share only an explicitly configured read-only connection."""
import asyncio
import pytest
from test_weknora_extension import adapter as adapter
from deerflow.knowledge import KnowledgeRequestContext
@pytest.fixture
def shared(adapter):
from deerflow_extension_weknora import WeKnoraProvider
_, calls, state, config, transport = adapter
space = config["spaces"][0]
space.pop("user_ids")
space["access_mode"] = "authenticated"
space["knowledge_base_ids"] = ["kb-1"]
return WeKnoraProvider(config, transport=transport), calls, state, config
@pytest.mark.asyncio
@pytest.mark.parametrize("user", ["new-registration-a", "new-registration-b"])
async def test_shared_new_users_can_browse_and_search_without_user_bindings(shared, user):
provider, calls, *_ = shared
context = KnowledgeRequestContext(user, "lead_agent", authenticated_user_id=user)
catalog = await provider.catalog(context=context, page=1, page_size=20, search="")
assert len(catalog.items) == 1
dataset = catalog.items[0].id
documents = await provider.documents(dataset, context=context, page=1, page_size=20, search="")
assert documents.items[0].id == "doc-1"
selected = KnowledgeRequestContext(user, "lead_agent", {"version": 1, "mode": "selected", "dataset_ids": [dataset]}, authenticated_user_id=user)
assert (await provider.search_scoped("Cedar", context=selected)).hits
await provider.entries_search("Cedar", context=selected)
assert all(r.method == "GET" or r.url.path.endswith(("/hybrid-search", "/wiki/search")) for r in calls)
@pytest.mark.asyncio
@pytest.mark.parametrize(("user", "verified"), [("new-user", None), ("default", None), ("new-user", "other-user"), ("", "")])
async def test_shared_requires_matching_verified_identity_before_http(shared, user, verified):
provider, calls, *_ = shared
context = KnowledgeRequestContext(user, "lead_agent", authenticated_user_id=verified)
with pytest.raises(PermissionError):
await provider.catalog(context=context, page=1, page_size=20, search="")
assert not calls
@pytest.mark.asyncio
async def test_shared_still_enforces_agent_and_base_restrictions(shared):
provider, calls, state, _ = shared
with pytest.raises(PermissionError):
await provider.catalog(context=KnowledgeRequestContext("new-user", "forbidden-agent", authenticated_user_id="new-user"), page=1, page_size=20, search="")
assert not calls
state["bases"] = [{"id": "kb-1", "name": "Allowed"}, {"id": "kb-private", "name": "Private"}]
context = KnowledgeRequestContext("new-user", "researcher", authenticated_user_id="new-user")
catalog = await provider.catalog(context=context, page=1, page_size=20, search="")
assert [item.name for item in catalog.items] == ["Allowed"]
state["document"]["knowledge_base_id"] = "kb-private"
with pytest.raises(PermissionError):
await provider.search_scoped("Cedar", context=context)
@pytest.mark.parametrize(
"changes",
[
{},
{"user_ids": None},
{"user_ids": []},
{"access_mode": "all"},
{"access_mode": "authenticated", "user_ids": ["alice"]},
{"access_mode": "authenticated", "user_ids": []},
{"access_mode": True},
],
)
def test_access_mode_must_be_explicit_and_unambiguous(adapter, changes):
from deerflow_extension_weknora import WeKnoraProvider
config = adapter[3]
space = dict(config["spaces"][0])
space.pop("user_ids")
with pytest.raises(ValueError):
WeKnoraProvider({"spaces": [{**space, **changes}]})
@pytest.mark.asyncio
async def test_verified_user_does_not_bypass_default_allowlist(adapter):
provider, calls, *_ = adapter
with pytest.raises(PermissionError):
await provider.catalog(context=KnowledgeRequestContext("new-user", "lead_agent", authenticated_user_id="new-user"), page=1, page_size=20, search="")
assert not calls
@pytest.mark.parametrize("credential", ["session", "pat", "anonymous", "auth_disabled", "internal"])
def test_gateway_propagates_only_verified_user_to_shared_catalog_and_tool(shared, monkeypatch, credential):
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from app.gateway.auth.models import User
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.deps import get_config
from app.gateway.internal_auth import create_internal_auth_headers
from app.gateway.routers import knowledge
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.extensions import EXTENSION_SNAPSHOT_CONTEXT_KEY, ExtensionRegistry
from deerflow.knowledge.tools import knowledge_search_tool
provider, calls, *_ = shared
users = [User(email=f"shared-{i}@example.com", password_hash="unused", system_role="user") for i in range(2)]
async def resolve_user(request):
return users[int(request.cookies["access_token"])]
async def resolve_pat(app, authorization):
return users[0], frozenset({"threads:read"})
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", resolve_user)
monkeypatch.setattr("app.gateway.auth.pat.authenticate_pat", resolve_pat)
monkeypatch.setattr("app.gateway.auth.pat.is_pat_allowed_route", lambda *args: True)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: AuthorizationConfig())
registry = ExtensionRegistry()
with registry.attributed_to("deerflow_extension_weknora:install"):
registry.knowledge_provider("weknora", provider)
loaded = registry.build()
config = AppConfig.model_validate(
{
"models": [],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"knowledge_base": {"enabled": True, "scope_selection_enabled": True},
"tools": [{"name": "knowledge_search", "group": "knowledge", "use": "deerflow.knowledge.tools:knowledge_search_tool", "provider": "weknora"}],
}
)
app = FastAPI()
app.add_middleware(AuthMiddleware)
app.state.extensions = loaded
app.include_router(knowledge.router)
app.dependency_overrides[get_config] = lambda: config
graph_builder = StateGraph(MessagesState, context_schema=dict)
graph_builder.add_node("tools", ToolNode([knowledge_search_tool]))
graph_builder.add_edge(START, "tools")
graph_builder.add_edge("tools", END)
graph = graph_builder.compile()
@app.get("/api/shared-search")
async def search(request: Request):
# These forged values must never become authentication evidence.
context = {
"user_id": str(request.state.user.id),
"authenticated_user_id": str(request.state.user.id),
"authenticated": True,
"app_config": config,
EXTENSION_SNAPSHOT_CONTEXT_KEY: loaded,
}
if request.query_params.get("child"):
context.update(is_subagent=True, agent_id="researcher")
result = await asyncio.create_task(
graph.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"id": "shared-search", "name": "knowledge_search", "args": {"query": "Cedar"}}])]},
context=context,
)
)
message = result["messages"][-1]
return {"content": message.content, "artifact": message.artifact}
headers = {}
cookies = {}
if credential == "session":
cookies["access_token"] = "0"
elif credential == "pat":
headers["Authorization"] = "Bearer synthetic-pat"
elif credential == "auth_disabled":
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
elif credential == "internal":
headers = create_internal_auth_headers()
allowed = credential in {"session", "pat"}
with TestClient(app, headers=headers, cookies=cookies) as client:
catalog = client.get("/api/knowledge/retrieval-catalog/datasets?agent_name=lead_agent&authenticated=true&user_id=alice")
assert catalog.status_code == (200 if allowed else 401 if credential == "anonymous" else 403)
search = client.get("/api/shared-search")
if allowed:
assert search.json()["artifact"]["knowledge_sources"]
assert client.get("/api/shared-search?child=1").json()["artifact"]["knowledge_sources"]
dataset = catalog.json()["items"][0]["id"]
assert client.get(f"/api/knowledge/retrieval-catalog/datasets/{dataset}/documents?agent_name=lead_agent").status_code == 200
if credential == "session":
client.cookies.set("access_token", "1")
assert client.get("/api/knowledge/retrieval-catalog/datasets?agent_name=lead_agent").status_code == 200
else:
assert search.status_code == 401 or search.json()["artifact"] is None
assert not calls