1678 lines
66 KiB
Python
1678 lines
66 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import Response
|
|
from starlette.datastructures import Headers, QueryParams
|
|
from starlette.requests import Request
|
|
|
|
from deerflow.config.agents_config import AgentConfig
|
|
from deerflow.config.llmwiki_config import (
|
|
LlmWikiConfig,
|
|
LlmWikiRuntimeOverride,
|
|
resolve_llmwiki_runtime,
|
|
)
|
|
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
|
from deerflow.persistence.llmwiki import MemoryLlmWikiStore
|
|
from deerflow.tools.builtins.llmwiki_search_tool import (
|
|
_sanitize_conversation_deposit_value as sanitize_tool_deposit_value,
|
|
)
|
|
from deerflow.tools.builtins.llmwiki_search_tool import (
|
|
selected_knowledge_base_ids,
|
|
)
|
|
|
|
|
|
def _request(
|
|
path: str,
|
|
*,
|
|
query: str = "",
|
|
cookies: dict[str, str] | None = None,
|
|
request_headers: dict[str, str] | None = None,
|
|
root_path: str = "",
|
|
) -> Request:
|
|
headers: list[tuple[bytes, bytes]] = [(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()]
|
|
if cookies:
|
|
headers.append((b"cookie", "; ".join(f"{key}={value}" for key, value in cookies.items()).encode()))
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": path,
|
|
"root_path": root_path,
|
|
"query_string": query.encode(),
|
|
"headers": headers,
|
|
"scheme": "http",
|
|
"server": ("testserver", 80),
|
|
"client": ("testclient", 50000),
|
|
}
|
|
)
|
|
|
|
|
|
def _first_gateway_match(method: str, path: str) -> str:
|
|
from app.gateway.app import app as gateway_app
|
|
|
|
for route in gateway_app.router.routes:
|
|
route_contexts = getattr(route, "effective_route_contexts", None)
|
|
candidates = route_contexts() if callable(route_contexts) else [route]
|
|
for candidate in candidates:
|
|
methods = getattr(candidate, "methods", None)
|
|
path_regex = getattr(candidate, "path_regex", None)
|
|
if methods is not None and method in methods and path_regex is not None and path_regex.match(path):
|
|
return str(getattr(candidate, "path", ""))
|
|
raise AssertionError(f"No route matched {method} {path}")
|
|
|
|
|
|
def test_empty_weknora_address_keeps_legacy_mode() -> None:
|
|
runtime = resolve_llmwiki_runtime(LlmWikiConfig())
|
|
|
|
assert runtime.provider == "legacy"
|
|
assert runtime.api_base_url == ""
|
|
|
|
|
|
def test_configured_weknora_address_enables_weknora_mode() -> None:
|
|
config = LlmWikiConfig.model_validate(
|
|
{
|
|
"weknora": {
|
|
"api_base_url": "http://weknora-app:8080/",
|
|
"web_base_url": "https://weknora.example.test/",
|
|
"admin_email": "admin@example.test",
|
|
"admin_password": "secret",
|
|
}
|
|
}
|
|
)
|
|
|
|
runtime = resolve_llmwiki_runtime(config)
|
|
|
|
assert runtime.provider == "weknora"
|
|
assert runtime.api_base_url == "http://weknora-app:8080"
|
|
assert runtime.web_base_url == "https://weknora.example.test"
|
|
assert runtime.admin_email == "admin@example.test"
|
|
assert runtime.admin_password == "secret"
|
|
|
|
|
|
def test_runtime_override_can_force_legacy_or_reset_to_file() -> None:
|
|
config = LlmWikiConfig.model_validate({"weknora": {"api_base_url": "http://weknora:8080"}})
|
|
|
|
forced_legacy = resolve_llmwiki_runtime(
|
|
config,
|
|
LlmWikiRuntimeOverride(enabled=True, api_base_url=""),
|
|
)
|
|
reset_to_file = resolve_llmwiki_runtime(
|
|
config,
|
|
LlmWikiRuntimeOverride(enabled=False, api_base_url=""),
|
|
)
|
|
|
|
assert forced_legacy.provider == "legacy"
|
|
assert reset_to_file.provider == "weknora"
|
|
|
|
|
|
def test_weknora_url_rejects_credentials_and_non_http_schemes() -> None:
|
|
with pytest.raises(ValueError):
|
|
LlmWikiConfig.model_validate({"weknora": {"api_base_url": "ftp://weknora.local"}})
|
|
with pytest.raises(ValueError):
|
|
LlmWikiConfig.model_validate({"weknora": {"api_base_url": "http://user:pass@weknora.local"}})
|
|
|
|
|
|
def test_weknora_embed_follows_only_protected_binary_redirects() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
assert llmwiki_router._should_follow_weknora_binary_redirect(
|
|
"/api/v1/knowledge/doc-one/preview"
|
|
)
|
|
assert llmwiki_router._should_follow_weknora_binary_redirect(
|
|
"/api/v1/knowledge-bases/kb-one/files"
|
|
)
|
|
assert not llmwiki_router._should_follow_weknora_binary_redirect(
|
|
"/api/v1/knowledge/doc-one"
|
|
)
|
|
assert not llmwiki_router._should_follow_weknora_binary_redirect(
|
|
"/api/v1/knowledge-bases/kb-one"
|
|
)
|
|
|
|
|
|
def test_agent_config_tracks_allowed_llmwiki_knowledge_bases() -> None:
|
|
config = AgentConfig(
|
|
name="Research agent",
|
|
llmwiki_knowledge_base_ids=["kb-one", "kb-two", "kb-one"],
|
|
)
|
|
|
|
assert config.llmwiki_knowledge_base_ids == ["kb-one", "kb-two"]
|
|
|
|
|
|
def test_conversation_selection_is_deduplicated_and_absence_is_distinct() -> None:
|
|
assert selected_knowledge_base_ids({}) is None
|
|
assert selected_knowledge_base_ids({"llmwiki_knowledge_base_ids": []}) == []
|
|
assert selected_knowledge_base_ids({"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]}) == ["kb-one", "kb-two"]
|
|
|
|
|
|
def test_llmwiki_rag_only_uses_explicit_conversation_selection() -> None:
|
|
from app.gateway.llmwiki_rag import resolve_requested_llmwiki_knowledge_base_ids
|
|
|
|
body = SimpleNamespace(
|
|
assistant_id="agent-with-bound-kb",
|
|
context={},
|
|
metadata={"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]},
|
|
)
|
|
|
|
assert resolve_requested_llmwiki_knowledge_base_ids(body) == ["kb-one", "kb-two"]
|
|
|
|
|
|
def test_weknora_frame_bootstrap_accepts_query_token_only_for_frame_route() -> None:
|
|
from app.gateway.deps import get_request_access_token
|
|
|
|
frame_request = _request(
|
|
"/api/llmwiki/knowledge-bases/local-kb/weknora/frame",
|
|
query="access_token=deerflow-token",
|
|
)
|
|
normal_request = _request("/api/models", query="access_token=deerflow-token")
|
|
|
|
assert get_request_access_token(frame_request) == "deerflow-token"
|
|
assert get_request_access_token(normal_request) is None
|
|
|
|
|
|
def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
|
|
from app.gateway import auth_middleware, csrf_middleware
|
|
from app.gateway.weknora_embed import sign_weknora_embed_payload
|
|
|
|
raw_cookie = sign_weknora_embed_payload(
|
|
{
|
|
"mapping_id": "local-kb",
|
|
"weknora_id": "remote-kb",
|
|
"user_id": "user-a",
|
|
"is_admin": False,
|
|
"can_write": False,
|
|
"exp": 4_102_444_800,
|
|
}
|
|
)
|
|
cookies = {"deerflow_weknora_embed": raw_cookie}
|
|
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/platform/knowledge-bases/kb", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/assets/index.js", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/assets/index.js", cookies=cookies))
|
|
for static_path in (
|
|
"/deerflow/css/app.css",
|
|
"/deerflow/fonts/app.woff2",
|
|
"/deerflow/img/logo.png",
|
|
"/deerflow/images/empty.svg",
|
|
"/deerflow/js/app.js",
|
|
"/deerflow/media/intro.mp4",
|
|
"/deerflow/static/chunk.js",
|
|
):
|
|
request = _request(static_path, cookies=cookies)
|
|
assert auth_middleware._is_weknora_embed_proxy_request(request)
|
|
assert csrf_middleware.is_weknora_embed_proxy_request(request)
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/api/v1/knowledge-search", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies))
|
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies))
|
|
assert csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
|
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}))
|
|
assert not csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}))
|
|
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/auth/me", cookies=cookies))
|
|
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/models", cookies=cookies))
|
|
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb", "name": "KB"}, "user-a", False
|
|
|
|
class Store:
|
|
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
|
|
|
async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]:
|
|
return [
|
|
{"id": "local-kb", "weknora_id": "remote-kb"},
|
|
{"id": "public-kb", "weknora_id": "other-public-kb"},
|
|
]
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
|
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
|
|
response = await llmwiki_router.create_weknora_detail_session(
|
|
_request(
|
|
"/api/llmwiki/knowledge-bases/local-kb/weknora/session",
|
|
request_headers={"X-Forwarded-Proto": "https"},
|
|
),
|
|
"local-kb",
|
|
tab="wiki",
|
|
)
|
|
|
|
payload = json.loads(response.body)
|
|
assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki"
|
|
cookie = response.headers["set-cookie"]
|
|
assert "deerflow_weknora_embed=" in cookie
|
|
assert "Secure" in cookie
|
|
assert "SameSite=none" in cookie
|
|
set_cookie_headers = [value.decode("latin-1") for key, value in response.raw_headers if key.lower() == b"set-cookie"]
|
|
assert any("Path=/" in value for value in set_cookie_headers)
|
|
assert any("Path=/deerflow" in value for value in set_cookie_headers)
|
|
raw = cookie.split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
|
ctx = llmwiki_router._verify_embed_cookie(raw)
|
|
assert ctx.mapping_id == "local-kb"
|
|
assert ctx.weknora_id == "remote-kb"
|
|
assert ctx.can_write is True
|
|
assert ctx.allowed_weknora_ids == ("remote-kb", "other-public-kb")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_session_endpoint_preserves_reverse_proxy_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb", "name": "KB"}, "user-a", False
|
|
|
|
class Store:
|
|
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
|
|
|
async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]:
|
|
return []
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
|
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
|
|
response = await llmwiki_router.create_weknora_detail_session(
|
|
_request(
|
|
"/api/llmwiki/knowledge-bases/local-kb/weknora/session",
|
|
request_headers={"X-Forwarded-Prefix": "/deerflow"},
|
|
),
|
|
"local-kb",
|
|
tab="wiki",
|
|
)
|
|
|
|
payload = json.loads(response.body)
|
|
assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_session_endpoint_uses_backend_prefix_when_frontend_has_different_prefix(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb", "name": "KB"}, "user-a", False
|
|
|
|
class Store:
|
|
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
|
|
|
async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]:
|
|
return []
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
|
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
|
|
response = await llmwiki_router.create_weknora_detail_session(
|
|
_request(
|
|
"/api/llmwiki/knowledge-bases/local-kb/weknora/session",
|
|
request_headers={"Referer": "https://example.test/magentweb/page/workspace/knowledge/local-kb"},
|
|
),
|
|
"local-kb",
|
|
tab="wiki",
|
|
)
|
|
|
|
payload = json.loads(response.body)
|
|
assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
assert "X-API-Key" not in request.headers
|
|
assert "Authorization" not in request.headers
|
|
assert json.loads(request.content) == {
|
|
"query": "deployment guide",
|
|
"knowledge_base_ids": ["kb-one", "kb-two"],
|
|
}
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"success": True,
|
|
"data": [
|
|
{
|
|
"id": "chunk-one",
|
|
"content": "matched passage",
|
|
"knowledge_id": "doc-one",
|
|
"knowledge_base_id": "kb-one",
|
|
"knowledge_title": "Guide",
|
|
"score": 0.93,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(
|
|
base_url="http://weknora.local:8080",
|
|
http_client=http_client,
|
|
)
|
|
results = await client.search("deployment guide", ["kb-one", "kb-two"])
|
|
|
|
assert requests[0].url.path == "/api/v1/knowledge-search"
|
|
assert results[0]["chunk_id"] == "chunk-one"
|
|
assert results[0]["title"] == "Guide"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_uses_admin_token_when_credentials_are_configured() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
if request.url.path == "/api/v1/auth/login":
|
|
assert json.loads(request.content) == {"email": "admin@example.test", "password": "secret"}
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"success": True,
|
|
"data": {
|
|
"token": "token-one",
|
|
"tenant": {"id": "tenant-one"},
|
|
},
|
|
},
|
|
)
|
|
assert request.headers["Authorization"] == "Bearer token-one"
|
|
assert request.headers["X-Tenant-ID"] == "tenant-one"
|
|
return httpx.Response(200, json={"success": True, "data": []})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(
|
|
base_url="http://weknora.local:8080",
|
|
http_client=http_client,
|
|
admin_email="admin@example.test",
|
|
admin_password="secret",
|
|
)
|
|
results = await client.search("deployment guide", ["kb-one"])
|
|
|
|
assert results == []
|
|
assert [request.url.path for request in requests] == [
|
|
"/api/v1/auth/login",
|
|
"/api/v1/knowledge-search",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_refreshes_admin_token_after_rejection() -> None:
|
|
login_count = 0
|
|
search_count = 0
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal login_count, search_count
|
|
if request.url.path == "/api/v1/auth/login":
|
|
login_count += 1
|
|
return httpx.Response(
|
|
200,
|
|
json={"success": True, "data": {"token": f"token-{login_count}"}},
|
|
)
|
|
search_count += 1
|
|
if search_count == 1:
|
|
assert request.headers["Authorization"] == "Bearer token-1"
|
|
return httpx.Response(401, json={"message": "expired"})
|
|
assert request.headers["Authorization"] == "Bearer token-2"
|
|
return httpx.Response(200, json={"success": True, "data": []})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(
|
|
base_url="http://weknora.local:8080",
|
|
http_client=http_client,
|
|
admin_email="admin@example.test",
|
|
admin_password="secret",
|
|
)
|
|
await client.search("deployment guide", ["kb-one"])
|
|
|
|
assert login_count == 2
|
|
assert search_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_merges_required_name_for_description_update() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
if request.method == "GET":
|
|
return httpx.Response(
|
|
200,
|
|
json={"success": True, "data": {"id": "kb-one", "name": "对话沉淀"}},
|
|
)
|
|
assert request.method == "PUT"
|
|
assert json.loads(request.content) == {"name": "对话沉淀", "description": "cmzs description"}
|
|
return httpx.Response(
|
|
200,
|
|
json={"success": True, "data": {"id": "kb-one", "name": "对话沉淀", "description": "cmzs description"}},
|
|
)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
updated = await client.update_knowledge_base("kb-one", {"description": "cmzs description"})
|
|
|
|
assert updated["description"] == "cmzs description"
|
|
assert [request.method for request in requests] == ["GET", "PUT"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_resolves_default_embedding_model_inside_weknora() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.method == "GET" and request.url.path == "/api/v1/models":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"success": True,
|
|
"data": [
|
|
{"id": "embed-default", "type": "Embedding", "is_default": True},
|
|
],
|
|
},
|
|
)
|
|
assert request.method == "POST"
|
|
assert request.url.path == "/api/v1/knowledge-bases"
|
|
assert json.loads(request.content)["embedding_model_id"] == "embed-default"
|
|
return httpx.Response(201, json={"success": True, "data": {"id": "kb-created", "name": "Docs"}})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(
|
|
base_url="http://weknora.local:8080",
|
|
http_client=http_client,
|
|
)
|
|
created = await client.create_knowledge_base(name="Docs")
|
|
|
|
assert created["id"] == "kb-created"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_creates_wiki_with_weknora_owned_models() -> None:
|
|
model_reads = 0
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal model_reads
|
|
if request.method == "GET" and request.url.path == "/api/v1/models":
|
|
model_reads += 1
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": [
|
|
{"id": "embed-one", "type": "Embedding", "is_default": True},
|
|
{"id": "qa-one", "type": "KnowledgeQA", "status": "active"},
|
|
]
|
|
},
|
|
)
|
|
payload = json.loads(request.content)
|
|
assert payload["embedding_model_id"] == "embed-one"
|
|
assert payload["summary_model_id"] == "qa-one"
|
|
assert payload["indexing_strategy"] == {
|
|
"vector_enabled": True,
|
|
"keyword_enabled": True,
|
|
"wiki_enabled": True,
|
|
"graph_enabled": False,
|
|
}
|
|
assert payload["wiki_config"]["synthesis_model_id"] == "qa-one"
|
|
return httpx.Response(201, json={"data": {"id": "wiki-one", "name": "Wiki"}})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
created = await client.create_knowledge_base(name="Wiki", wiki_enabled=True)
|
|
|
|
assert created["id"] == "wiki-one"
|
|
assert model_reads == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_imports_url_manual_content_and_lists_wiki_pages() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith("/knowledge/url"):
|
|
assert json.loads(request.content) == {"url": "https://example.test/guide"}
|
|
return httpx.Response(201, json={"data": {"id": "url-one"}})
|
|
if request.url.path.endswith("/knowledge/manual"):
|
|
assert json.loads(request.content) == {
|
|
"title": "Guide",
|
|
"content": "# Guide",
|
|
"status": "publish",
|
|
}
|
|
return httpx.Response(201, json={"data": {"id": "manual-one"}})
|
|
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages"
|
|
assert request.url.params["query"] == "guide"
|
|
return httpx.Response(
|
|
200,
|
|
json={"pages": [{"slug": "guide", "title": "Guide"}], "total": 1, "page": 1, "page_size": 50},
|
|
)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
await client.import_document_url("kb-one", url="https://example.test/guide")
|
|
await client.create_manual_document("kb-one", title="Guide", content="# Guide")
|
|
pages = await client.list_wiki_pages("kb-one", query="guide")
|
|
|
|
assert pages["total"] == 1
|
|
assert pages["pages"][0]["slug"] == "guide"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_reads_ordered_wiki_index_groups() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/index"
|
|
assert request.url.params["types"] == "entity,concept"
|
|
assert request.url.params["limit"] == "50"
|
|
assert request.url.params["cursor"] == "next-page"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": {
|
|
"intro": "# Wiki Index",
|
|
"version": 2,
|
|
"groups": [
|
|
{
|
|
"type": "entity",
|
|
"total": 1,
|
|
"items": [{"slug": "entity/one", "title": "实体一"}],
|
|
}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
index = await client.get_wiki_index(
|
|
"kb-one",
|
|
types=["entity", "concept"],
|
|
limit=50,
|
|
cursor="next-page",
|
|
)
|
|
|
|
assert index["groups"][0]["items"][0]["slug"] == "entity/one"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_creates_updates_and_deletes_wiki_pages() -> None:
|
|
seen: list[tuple[str, str]] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
seen.append((request.method, request.url.path))
|
|
if request.method == "POST":
|
|
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages"
|
|
assert json.loads(request.content) == {"slug": "folder/guide", "title": "Guide", "content": "# Guide"}
|
|
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
|
|
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"
|
|
if request.method == "PUT":
|
|
assert json.loads(request.content) == {"title": "Guide", "content": "# Guide"}
|
|
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
|
|
if request.method == "DELETE":
|
|
return httpx.Response(204)
|
|
return httpx.Response(405)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
created = await client.create_wiki_page("kb-one", {"slug": "folder/guide", "title": "Guide", "content": "# Guide"})
|
|
updated = await client.update_wiki_page("kb-one", "folder/guide", {"title": "Guide", "content": "# Guide"})
|
|
await client.delete_wiki_page("kb-one", "folder/guide")
|
|
|
|
assert created["slug"] == "folder/guide"
|
|
assert updated["slug"] == "folder/guide"
|
|
assert seen == [
|
|
("POST", "/api/v1/knowledgebase/kb-one/wiki/pages"),
|
|
("PUT", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
|
|
("DELETE", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path == "/api/v1/chunks/doc-one":
|
|
assert request.url.params["page"] == "2"
|
|
assert request.url.params["page_size"] == "10"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"success": True,
|
|
"data": [{"id": "chunk-one", "content": "parsed content", "chunk_index": 10}],
|
|
"total": 31,
|
|
"page": 2,
|
|
"page_size": 10,
|
|
},
|
|
)
|
|
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/graph"
|
|
assert request.url.params["mode"] == "overview"
|
|
assert request.url.params["limit"] == "50"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"success": True,
|
|
"data": {
|
|
"nodes": [{"slug": "entity/one", "title": "Entity One", "page_type": "entity"}],
|
|
"edges": [],
|
|
"meta": {"mode": "overview", "total": 1, "returned": 1},
|
|
},
|
|
},
|
|
)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
chunks = await client.list_chunks("doc-one", page=2, page_size=10)
|
|
graph = await client.get_wiki_graph("kb-one", limit=50)
|
|
|
|
assert chunks["total"] == 31
|
|
assert chunks["items"][0]["content"] == "parsed content"
|
|
assert graph["nodes"][0]["slug"] == "entity/one"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_proxies_original_document_preview_without_credentials() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/api/v1/knowledge/doc-one/preview"
|
|
assert "X-API-Key" not in request.headers
|
|
assert "Authorization" not in request.headers
|
|
return httpx.Response(200, content=b"preview", headers={"Content-Type": "text/plain; charset=utf-8"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
content, media_type = await client.get_document_preview("doc-one")
|
|
|
|
assert content == b"preview"
|
|
assert media_type == "text/plain"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_client_fetches_protected_wiki_image() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/api/v1/knowledge-bases/kb-one/files"
|
|
assert request.url.params["file_path"] == "resource://wiki/figure.png"
|
|
return httpx.Response(200, content=b"png-bytes", headers={"Content-Type": "image/png"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
|
content, media_type = await client.get_knowledge_base_file(
|
|
"kb-one",
|
|
"resource://wiki/figure.png",
|
|
)
|
|
|
|
assert content == b"png-bytes"
|
|
assert media_type == "image/png"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_memory_store_enforces_owner_and_public_visibility() -> None:
|
|
store = MemoryLlmWikiStore()
|
|
created = await store.create_mapping(
|
|
weknora_id="wk-private",
|
|
owner_user_id="user-a",
|
|
name="Private notes",
|
|
description="",
|
|
kb_type="document",
|
|
)
|
|
|
|
assert [row["id"] for row in await store.list_visible("user-a", scope="personal")] == [created["id"]]
|
|
assert await store.list_visible("user-b", scope="personal") == []
|
|
assert await store.get_authorized(created["id"], "user-b", write=False, is_admin=False) is None
|
|
|
|
published = await store.request_publish(created["id"], "user-a", is_admin=False)
|
|
assert published and published["publication_status"] == "published"
|
|
assert [row["id"] for row in await store.list_visible("user-b", scope="public")] == [created["id"]]
|
|
assert await store.get_authorized(created["id"], "user-b", write=False, is_admin=False) is not None
|
|
assert await store.get_authorized(created["id"], "user-b", write=True, is_admin=False) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_deposit_mapping_is_system_owned_and_published(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway import llmwiki_deposit
|
|
|
|
class FakeClient:
|
|
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
|
return []
|
|
|
|
async def create_knowledge_base(self, **_: object) -> dict[str, object]:
|
|
return {
|
|
"id": "remote-deposit",
|
|
"name": "对话沉淀",
|
|
"description": "global",
|
|
"type": "document",
|
|
}
|
|
|
|
store = MemoryLlmWikiStore()
|
|
app = SimpleNamespace(state=SimpleNamespace(llmwiki_store=store))
|
|
monkeypatch.setattr(llmwiki_deposit, "_client", lambda _: FakeClient())
|
|
|
|
row = await llmwiki_deposit.ensure_conversation_deposit_knowledge_base(app, owner_user_id="user-a")
|
|
|
|
assert row is not None
|
|
assert row["owner_user_id"] == "system"
|
|
assert row["publication_status"] == "published"
|
|
assert row["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
|
assert row["id"] not in [item["id"] for item in await store.list_visible("user-a", scope="personal")]
|
|
assert [item["id"] for item in await store.list_visible("user-a", scope="public")] == [row["id"]]
|
|
|
|
markdown = llmwiki_deposit._format_markdown(
|
|
llmwiki_deposit.ConversationTurnDeposit(
|
|
thread_id="thread-one",
|
|
question="question",
|
|
answer="answer",
|
|
human_message_id="human-one",
|
|
assistant_message_id="assistant-one",
|
|
assistant_id=None,
|
|
knowledge_base_ids=[],
|
|
created_by_user_id="user-a",
|
|
),
|
|
"title",
|
|
)
|
|
assert "DeerFlow" not in markdown
|
|
assert "source: cmzs" in markdown
|
|
assert "#cmzs" in markdown
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_conversation_deposit_description_is_refreshed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway import llmwiki_deposit
|
|
|
|
updates: list[tuple[str, dict[str, object]]] = []
|
|
|
|
class FakeClient:
|
|
async def update_knowledge_base(self, knowledge_base_id: str, changes: dict[str, object]) -> dict[str, object]:
|
|
updates.append((knowledge_base_id, changes))
|
|
return {"id": knowledge_base_id, **changes}
|
|
|
|
store = MemoryLlmWikiStore()
|
|
created = await store.create_mapping(
|
|
weknora_id="remote-deposit",
|
|
owner_user_id="system",
|
|
name=llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
|
description="old description",
|
|
kb_type="document",
|
|
)
|
|
await store.request_publish(created["id"], "system", is_admin=True)
|
|
app = SimpleNamespace(state=SimpleNamespace(llmwiki_store=store))
|
|
monkeypatch.setattr(llmwiki_deposit, "_client", lambda _: FakeClient())
|
|
|
|
row = await llmwiki_deposit.ensure_conversation_deposit_knowledge_base(app, owner_user_id="user-a")
|
|
|
|
assert row is not None
|
|
assert row["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
|
assert updates == [
|
|
(
|
|
"remote-deposit",
|
|
{"description": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_user_owned_conversation_deposit_is_adopted(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway import llmwiki_deposit
|
|
|
|
class FakeClient:
|
|
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
|
return [
|
|
{
|
|
"id": "remote-deposit",
|
|
"name": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
|
"description": "DeerFlow legacy description",
|
|
}
|
|
]
|
|
|
|
async def update_knowledge_base(self, knowledge_base_id: str, changes: dict[str, object]) -> dict[str, object]:
|
|
return {"id": knowledge_base_id, **changes}
|
|
|
|
store = MemoryLlmWikiStore()
|
|
legacy = await store.create_mapping(
|
|
weknora_id="remote-deposit",
|
|
owner_user_id="user-a",
|
|
name=llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
|
description="DeerFlow legacy description",
|
|
kb_type="document",
|
|
)
|
|
await store.request_publish(legacy["id"], "user-a", is_admin=False)
|
|
app = SimpleNamespace(state=SimpleNamespace(llmwiki_store=store))
|
|
monkeypatch.setattr(llmwiki_deposit, "_client", lambda _: FakeClient())
|
|
|
|
row = await llmwiki_deposit.ensure_conversation_deposit_knowledge_base(app, owner_user_id="user-a")
|
|
|
|
assert row is not None
|
|
assert row["id"] == legacy["id"]
|
|
assert row["owner_user_id"] == "system"
|
|
assert row["publication_status"] == "published"
|
|
assert row["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
|
assert await store.list_visible("user-a", scope="personal") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_listing_conversation_deposit_refreshes_remote_description(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.gateway import llmwiki_deposit
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
updates: list[tuple[str, dict[str, object]]] = []
|
|
store = MemoryLlmWikiStore()
|
|
created = await store.create_mapping(
|
|
weknora_id="remote-deposit",
|
|
owner_user_id="system",
|
|
name=llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
|
description="DeerFlow legacy description",
|
|
kb_type="document",
|
|
)
|
|
await store.request_publish(created["id"], "system", is_admin=True)
|
|
|
|
class Client:
|
|
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
|
return [
|
|
{
|
|
"id": "remote-deposit",
|
|
"name": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
|
"description": "DeerFlow legacy description",
|
|
}
|
|
]
|
|
|
|
async def create_knowledge_base(self, **_: object) -> dict[str, object]:
|
|
raise AssertionError("The existing deposit should be reused")
|
|
|
|
async def update_knowledge_base(self, knowledge_base_id: str, changes: dict[str, object]) -> dict[str, object]:
|
|
updates.append((knowledge_base_id, changes))
|
|
return {"id": knowledge_base_id, **changes}
|
|
|
|
async def actor(_: object) -> tuple[str, bool]:
|
|
return "user-a", False
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_actor", actor)
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: store)
|
|
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
|
|
|
|
payload = await llmwiki_router.list_knowledge_bases(object(), scope="public")
|
|
|
|
assert payload["knowledge_bases"][0]["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
|
assert "DeerFlow" not in json.dumps(payload, ensure_ascii=False)
|
|
assert updates == [
|
|
(
|
|
"remote-deposit",
|
|
{"description": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
|
)
|
|
]
|
|
|
|
|
|
def test_conversation_deposit_history_hides_legacy_brand() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
value = {
|
|
"content": "This page was generated by DeerFlow.",
|
|
"metadata": {"source": "deer-flow", "tags": ["DEER_FLOW", "keep"]},
|
|
}
|
|
|
|
gateway_value = llmwiki_router._sanitize_conversation_deposit_value(value)
|
|
tool_value = sanitize_tool_deposit_value(value)
|
|
|
|
assert gateway_value == tool_value
|
|
assert gateway_value == {
|
|
"content": "This page was generated by cmzs.",
|
|
"metadata": {"source": "cmzs", "tags": ["cmzs", "keep"]},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_knowledge_bases_personal_scope_filters_to_actor_for_admin(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
calls: list[dict[str, object]] = []
|
|
|
|
class Store:
|
|
async def list_visible(self, user_id: str, *, scope: str, is_admin: bool) -> list[dict[str, object]]:
|
|
calls.append({"user_id": user_id, "scope": scope, "is_admin": is_admin})
|
|
return [
|
|
{
|
|
"id": "local-admin",
|
|
"weknora_id": "remote-admin",
|
|
"owner_user_id": user_id,
|
|
"name": "Admin private",
|
|
"description": "",
|
|
"kb_type": "document",
|
|
"publication_status": "private",
|
|
}
|
|
]
|
|
|
|
class Client:
|
|
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
|
return [{"id": "remote-admin", "name": "Admin private"}]
|
|
|
|
async def actor(_: object) -> tuple[str, bool]:
|
|
return "admin-id", True
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_actor", actor)
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
|
|
|
|
payload = await llmwiki_router.list_knowledge_bases(object(), scope="personal")
|
|
|
|
assert calls == [{"user_id": "admin-id", "scope": "personal", "is_admin": False}]
|
|
assert payload["knowledge_bases"][0]["is_owner"] is True
|
|
|
|
|
|
def test_mapping_view_distinguishes_owner_from_admin_write_power() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
view = llmwiki_router._mapping_view(
|
|
{
|
|
"id": "local-other",
|
|
"weknora_id": "remote-other",
|
|
"owner_user_id": "user-b",
|
|
"name": "Other private",
|
|
"description": "",
|
|
"kb_type": "document",
|
|
"publication_status": "private",
|
|
},
|
|
{"id": "remote-other", "name": "Other private"},
|
|
actor_user_id="admin-id",
|
|
is_admin=True,
|
|
)
|
|
|
|
assert view["is_owner"] is False
|
|
assert view["can_write"] is True
|
|
|
|
|
|
def test_only_system_owned_named_base_is_treated_as_conversation_deposit() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
personal = {
|
|
"id": "personal-named-deposit",
|
|
"owner_user_id": "user-a",
|
|
"name": "对话沉淀",
|
|
"publication_status": "private",
|
|
}
|
|
system = {
|
|
"id": "system-deposit",
|
|
"owner_user_id": "system",
|
|
"name": "对话沉淀",
|
|
"publication_status": "published",
|
|
}
|
|
|
|
assert not llmwiki_router._is_conversation_deposit_mapping(personal)
|
|
assert llmwiki_router._is_conversation_deposit_mapping(system)
|
|
system_view = llmwiki_router._mapping_view(
|
|
system,
|
|
{
|
|
"id": "remote-system",
|
|
"name": "对话沉淀",
|
|
"description": "DeerFlow legacy description",
|
|
},
|
|
actor_user_id="admin-id",
|
|
is_admin=True,
|
|
)
|
|
assert system_view["can_write"] is False
|
|
assert system_view["description"] == "cmzs 全局问答沉淀知识库。所有用户可见,不默认参与问答检索。"
|
|
assert "DeerFlow" not in system_view["description"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_deposit_requires_thread_access(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
class ThreadStore:
|
|
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool) -> bool:
|
|
assert thread_id == "thread-other"
|
|
assert user_id == "user-a"
|
|
assert require_existing is True
|
|
return False
|
|
|
|
async def actor(_: object) -> tuple[str, bool]:
|
|
return "user-a", False
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_actor", actor)
|
|
monkeypatch.setattr(llmwiki_router, "get_thread_store", lambda _: ThreadStore())
|
|
monkeypatch.setattr(
|
|
llmwiki_router,
|
|
"get_resolved_llmwiki_runtime",
|
|
lambda _: SimpleNamespace(weknora_enabled=True, provider="weknora"),
|
|
)
|
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(config=object())))
|
|
body = llmwiki_router.ConversationDepositCreate(
|
|
thread_id="thread-other",
|
|
question="question",
|
|
answer="answer",
|
|
assistant_message_id="assistant-one",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await llmwiki_router.create_conversation_deposit(
|
|
request,
|
|
SimpleNamespace(add_task=lambda *_: None),
|
|
body,
|
|
)
|
|
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_context_prefers_wiki_page_over_raw_chunks(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
class Client:
|
|
async def get_chunk_context(self, chunk_id: str, *, expected_knowledge_base_id: str, radius: int) -> list[dict[str, object]]:
|
|
assert chunk_id == "chunk-one"
|
|
assert expected_knowledge_base_id == "remote-kb"
|
|
return [
|
|
{
|
|
"id": "chunk-one",
|
|
"chunk_index": 0,
|
|
"content": "raw split chunk",
|
|
"metadata": {"wiki_page_slug": "people/alice"},
|
|
}
|
|
]
|
|
|
|
async def get_wiki_page(self, knowledge_base_id: str, slug: str) -> dict[str, object]:
|
|
assert knowledge_base_id == "remote-kb"
|
|
assert slug == "people/alice"
|
|
return {
|
|
"id": "wiki-one",
|
|
"slug": "people/alice",
|
|
"title": "Alice",
|
|
"summary": "clean summary",
|
|
"content": "clean llmwiki page",
|
|
"source_refs": ["chunk-one"],
|
|
}
|
|
|
|
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb", "name": "Public LLMWiki"}, "user-a", False
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
|
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
|
|
|
|
payload = await llmwiki_router.get_source_context(object(), knowledge_base_id="local-kb", chunk_id="chunk-one")
|
|
|
|
assert payload["data"]["display_mode"] == "wiki"
|
|
assert payload["data"]["wiki_page"]["content"] == "clean llmwiki page"
|
|
assert payload["data"]["chunks"][0]["content"] == "raw split chunk"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_cleans_owner_mapping_when_remote_is_already_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
deleted: list[str] = []
|
|
|
|
class MissingClient:
|
|
async def delete_knowledge_base(self, _: str) -> None:
|
|
raise WeKnoraError("WeKnora resource was not found", status_code=404)
|
|
|
|
class Store:
|
|
async def delete_mapping(self, mapping_id: str) -> None:
|
|
deleted.append(mapping_id)
|
|
|
|
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
|
return {"weknora_id": "remote-missing"}, "owner", False
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
|
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: MissingClient())
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
|
|
await llmwiki_router.delete_knowledge_base(object(), "mapping-one")
|
|
|
|
assert deleted == ["mapping-one"]
|
|
|
|
|
|
def test_weknora_iframe_cookie_is_signed_and_short_lived() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
raw = llmwiki_router._sign_embed_payload(
|
|
{
|
|
"mapping_id": "local-kb",
|
|
"weknora_id": "remote-kb",
|
|
"user_id": "user-a",
|
|
"is_admin": False,
|
|
"can_write": True,
|
|
"exp": 4_102_444_800,
|
|
}
|
|
)
|
|
|
|
ctx = llmwiki_router._verify_embed_cookie(raw)
|
|
|
|
assert ctx.mapping_id == "local-kb"
|
|
assert ctx.weknora_id == "remote-kb"
|
|
assert ctx.can_write is True
|
|
|
|
with pytest.raises(HTTPException):
|
|
llmwiki_router._verify_embed_cookie(raw + "tampered")
|
|
|
|
|
|
def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
|
|
assert _first_gateway_match("POST", "/api/v1/auth/login/username") == "/api/v1/auth/login/username"
|
|
assert _first_gateway_match("POST", "/api/v1/knowledge-search") == "/api/v1/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/api/llmwiki/weknora-embed/api/v1/auth/me") == "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/deerflow/platform/knowledge-bases/remote-kb") == "/deerflow/platform/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/deerflow/assets/index.js") == "/deerflow/assets/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/deerflow/js/index.js") == "/deerflow/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/deerflow/css/index.css") == "/deerflow/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/deerflow/fonts/index.woff2") == "/deerflow/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/deerflow/api/v1/knowledge-search") == "/deerflow/api/v1/{proxied_path:path}"
|
|
assert _first_gateway_match("GET", "/js/index.js") == "/{proxied_path:path}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("location", "target_base_url", "expected"),
|
|
[
|
|
("/platform/knowledge-bases/kb?tab=wiki", "http://weknora-web:8080", "/deerflow/platform/knowledge-bases/kb?tab=wiki"),
|
|
("/deerflow/platform/knowledge-bases/kb", "http://weknora-web:8080", "/deerflow/platform/knowledge-bases/kb"),
|
|
("/deerflow-api/api/v1/auth/me", "http://gateway/deerflow-api", "/deerflow/api/v1/auth/me"),
|
|
(
|
|
"http://gateway/deerflow-api/api/v1/auth/me?next=1#top",
|
|
"http://gateway/deerflow-api",
|
|
"/deerflow/api/v1/auth/me?next=1#top",
|
|
),
|
|
("https://external.test/login", "http://gateway/deerflow-api", "https://external.test/login"),
|
|
],
|
|
)
|
|
def test_weknora_redirect_location_is_normalized_for_public_prefix(
|
|
location: str,
|
|
target_base_url: str,
|
|
expected: str,
|
|
) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
assert llmwiki_router._rewrite_weknora_location_header(location, "/deerflow", target_base_url) == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_auth_errors(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from app.gateway.auth_middleware import AuthMiddleware
|
|
from app.gateway.csrf_middleware import CSRFMiddleware
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
from app.gateway.weknora_embed import WEKNORA_EMBED_COOKIE, sign_weknora_embed_payload
|
|
|
|
class Store:
|
|
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
|
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
|
|
|
runtime = SimpleNamespace(
|
|
web_base_url="http://weknora-web.test",
|
|
api_base_url="http://weknora-api.test",
|
|
)
|
|
upstream_requests: list[tuple[str, str, dict[str, str]]] = []
|
|
|
|
async def fake_session(_: object, *, force_refresh: bool = False) -> dict[str, object]:
|
|
return {"token": "admin-token", "tenant": {"id": "tenant-1"}}
|
|
|
|
class FakeUpstreamClient:
|
|
def __init__(self, **_: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> FakeUpstreamClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
return None
|
|
|
|
async def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
**__: object,
|
|
) -> httpx.Response:
|
|
upstream_requests.append((method, url, headers))
|
|
path = httpx.URL(url).path
|
|
if url.startswith(runtime.api_base_url):
|
|
return httpx.Response(200, json={"success": True, "data": {"id": "admin"}})
|
|
if path == "/platform/knowledge-bases/remote-kb":
|
|
return httpx.Response(
|
|
200,
|
|
text=('<html><head><script src="/assets/main.js"></script><link rel="stylesheet" href="css/app.css"><script src="/config.js"></script></head><body></body></html>'),
|
|
headers={"content-type": "text/html; charset=utf-8"},
|
|
)
|
|
if path == "/assets/main.js":
|
|
return httpx.Response(
|
|
200,
|
|
text=('const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")'),
|
|
headers={"content-type": "application/javascript"},
|
|
)
|
|
if path == "/css/app.css":
|
|
return httpx.Response(
|
|
200,
|
|
text='@font-face{src:url("/fonts/app.woff2")}',
|
|
headers={"content-type": "text/css"},
|
|
)
|
|
if path in {"/config.js", "/static/chunk.js", "/assets/deep.js"}:
|
|
return httpx.Response(200, text=f'window.loaded="{path}"', headers={"content-type": "application/javascript"})
|
|
if path == "/fonts/app.woff2":
|
|
return httpx.Response(200, content=b"font", headers={"content-type": "font/woff2"})
|
|
return httpx.Response(404, text=f"missing upstream path: {path}")
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: runtime)
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
monkeypatch.setattr(llmwiki_router, "_get_weknora_admin_session", fake_session)
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(llmwiki_router.httpx, "AsyncClient", FakeUpstreamClient)
|
|
|
|
test_app = FastAPI()
|
|
test_app.add_middleware(AuthMiddleware)
|
|
test_app.add_middleware(CSRFMiddleware)
|
|
test_app.include_router(llmwiki_router.router)
|
|
test_app.include_router(llmwiki_router.proxy_router, prefix="/deerflow")
|
|
test_app.include_router(llmwiki_router.proxy_router)
|
|
|
|
cookie = sign_weknora_embed_payload(
|
|
{
|
|
"mapping_id": "local-kb",
|
|
"weknora_id": "remote-kb",
|
|
"user_id": "user-a",
|
|
"is_admin": False,
|
|
"can_write": True,
|
|
"exp": 4_102_444_800,
|
|
}
|
|
)
|
|
transport = httpx.ASGITransport(app=test_app)
|
|
async with real_async_client(transport=transport, base_url="http://deerflow.test") as client:
|
|
client.cookies.set(WEKNORA_EMBED_COOKIE, cookie)
|
|
|
|
page = await client.get("/deerflow/platform/knowledge-bases/remote-kb?tab=wiki")
|
|
assert page.status_code == 200
|
|
assert 'src="/deerflow/assets/main.js"' in page.text
|
|
assert 'href="/deerflow/css/app.css"' in page.text
|
|
assert 'src="/deerflow/config.js"' in page.text
|
|
|
|
script = await client.get("/deerflow/assets/main.js")
|
|
assert script.status_code == 200
|
|
assert '"/deerflow/static/chunk.js"' in script.text
|
|
assert '"deerflow/assets/lazy.css"' in script.text
|
|
assert '"deerflow/assets/lazy.js"' in script.text
|
|
assert '"/deerflow/assets/lazy.css"' not in script.text
|
|
assert "matcher=/platform\\//" in script.text
|
|
assert 'fetch("/deerflow/api/v1/auth/me")' in script.text
|
|
|
|
stylesheet = await client.get("/deerflow/css/app.css")
|
|
assert stylesheet.status_code == 200
|
|
assert 'url("/deerflow/fonts/app.woff2")' in stylesheet.text
|
|
|
|
for path in (
|
|
"/deerflow/config.js",
|
|
"/deerflow/static/chunk.js",
|
|
"/deerflow/fonts/app.woff2",
|
|
"/deerflow/platform/knowledge-bases/remote-kb/assets/deep.js",
|
|
):
|
|
response = await client.get(path)
|
|
assert response.status_code == 200, f"{path}: {response.status_code} {response.text}"
|
|
|
|
api = await client.get("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me")
|
|
assert api.status_code == 200
|
|
|
|
api_request = next(request for request in upstream_requests if request[1].startswith(runtime.api_base_url))
|
|
assert api_request[2]["Authorization"] == "Bearer admin-token"
|
|
assert api_request[2]["X-Tenant-ID"] == "tenant-1"
|
|
|
|
|
|
def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
|
)
|
|
injection = llmwiki_router._weknora_embed_injection(
|
|
allowed_path="/platform/knowledge-bases/remote-kb",
|
|
tab="wiki",
|
|
session={},
|
|
ctx=ctx,
|
|
)
|
|
|
|
assert "#app > .main > .aside_box" in injection
|
|
assert '".main > .aside_box"' in injection
|
|
|
|
|
|
def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
|
)
|
|
html = b'<html><head><script src="/assets/index.js"></script></head><body>fetch("/api/v1/auth/me")</body></html>'
|
|
|
|
injected = llmwiki_router._inject_weknora_embed_html(
|
|
html,
|
|
allowed_path="/deerflow/platform/knowledge-bases/remote-kb",
|
|
tab="wiki",
|
|
session={},
|
|
ctx=ctx,
|
|
public_prefix="/deerflow",
|
|
).decode()
|
|
|
|
assert 'src="/deerflow/assets/index.js"' in injected
|
|
assert 'fetch("/deerflow/api/v1/auth/me")' in injected
|
|
assert '"apiPrefix":"/deerflow/api/llmwiki/weknora-embed"' in injected
|
|
assert '"allowedPath":"/deerflow/platform/knowledge-bases/remote-kb"' in injected
|
|
assert '"allowedWeKnoraIds":["remote-kb","other-visible-kb"]' in injected
|
|
assert ".breadcrumb-link.dropdown" not in injected
|
|
assert "reloadForKnowledgeBaseSwitch" in injected
|
|
assert "function protectedImageApiUrl" in injected
|
|
assert 'img[data-protected-src]' in injected
|
|
assert 'encodeURIComponent(cfg.weknoraId)' in injected
|
|
assert "image.src = proxyUrl;" in injected
|
|
assert "URL.createObjectURL(blob)" not in injected
|
|
assert 'attributeFilter: ["data-protected-src", "src"]' in injected
|
|
|
|
|
|
def test_weknora_iframe_filters_kb_list_to_signed_visible_mappings() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
|
)
|
|
response = httpx.Response(
|
|
200,
|
|
json={
|
|
"success": True,
|
|
"data": [
|
|
{"id": "remote-kb", "name": "Allowed"},
|
|
{"id": "other-visible-kb", "name": "Also allowed"},
|
|
{"id": "other-kb", "name": "Blocked"},
|
|
],
|
|
},
|
|
)
|
|
|
|
filtered = llmwiki_router._filter_knowledge_base_list_response(response, ctx)
|
|
|
|
assert filtered is not None
|
|
payload = json.loads(filtered)
|
|
assert payload["data"] == [
|
|
{"id": "remote-kb", "name": "Allowed"},
|
|
{"id": "other-visible-kb", "name": "Also allowed"},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_iframe_switch_rechecks_selected_mapping_permissions(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
class Store:
|
|
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, str] | None:
|
|
if weknora_id == "other-visible-kb":
|
|
return {"id": "public-kb", "weknora_id": weknora_id, "owner_user_id": "owner-b"}
|
|
return None
|
|
|
|
async def get_authorized(self, mapping_id: str, _: str, *, write: bool, **__: object) -> dict[str, str] | None:
|
|
if mapping_id != "public-kb":
|
|
return None
|
|
if write:
|
|
return None
|
|
return {"id": "public-kb", "weknora_id": "other-visible-kb", "owner_user_id": "owner-b"}
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
|
original = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=True,
|
|
exp=4_102_444_800,
|
|
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
|
)
|
|
|
|
selected = await llmwiki_router._switch_embed_context(
|
|
_request("/platform/knowledge-bases/other-visible-kb"),
|
|
original,
|
|
"other-visible-kb",
|
|
)
|
|
|
|
assert selected.mapping_id == "public-kb"
|
|
assert selected.weknora_id == "other-visible-kb"
|
|
assert selected.can_write is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_platform_proxy_reissues_cookie_for_native_kb_switch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
current = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
|
)
|
|
selected = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="public-kb",
|
|
weknora_id="other-visible-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
allowed_weknora_ids=current.allowed_weknora_ids,
|
|
)
|
|
seen: dict[str, object] = {}
|
|
|
|
async def read_context(_: Request) -> object:
|
|
return current
|
|
|
|
async def switch_context(_: Request, ctx: object, weknora_id: str) -> object:
|
|
seen["current"] = ctx
|
|
seen["target"] = weknora_id
|
|
return selected
|
|
|
|
async def proxy_request(*_: object, **kwargs: object) -> Response:
|
|
seen.update(kwargs)
|
|
return Response("ok")
|
|
|
|
monkeypatch.setattr(llmwiki_router, "_read_embed_context", read_context)
|
|
monkeypatch.setattr(llmwiki_router, "_switch_embed_context", switch_context)
|
|
monkeypatch.setattr(
|
|
llmwiki_router,
|
|
"_runtime_for_iframe_proxy",
|
|
lambda _: SimpleNamespace(web_base_url="http://weknora-web"),
|
|
)
|
|
monkeypatch.setattr(llmwiki_router, "_proxy_weknora_request", proxy_request)
|
|
|
|
response = await llmwiki_router.proxy_weknora_platform_page(
|
|
_request("/platform/knowledge-bases/other-visible-kb", request_headers={"X-Forwarded-Proto": "https"}),
|
|
"knowledge-bases/other-visible-kb",
|
|
)
|
|
|
|
assert seen["current"] == current
|
|
assert seen["target"] == "other-visible-kb"
|
|
assert seen["upstream_path"] == "/platform/knowledge-bases/other-visible-kb"
|
|
raw_cookie = response.headers["set-cookie"].split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
|
switched_context = llmwiki_router._verify_embed_cookie(raw_cookie)
|
|
assert switched_context.mapping_id == "public-kb"
|
|
assert switched_context.weknora_id == "other-visible-kb"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_deposit_iframe_sanitizes_legacy_brand(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
async def fake_session(_: object, *, force_refresh: bool = False) -> dict[str, object]:
|
|
return {"token": "token", "tenant": {}}
|
|
|
|
async def authorize(*_: object) -> None:
|
|
return None
|
|
|
|
class FakeAsyncClient:
|
|
def __init__(self, **_: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
return None
|
|
|
|
async def request(self, *_: object, **__: object) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
json={"success": True, "data": {"summary": "Generated by DeerFlow", "source": "deer-flow"}},
|
|
)
|
|
|
|
class FakeRequest:
|
|
method = "GET"
|
|
headers = Headers({})
|
|
query_params = QueryParams("")
|
|
|
|
async def body(self) -> bytes:
|
|
return b""
|
|
|
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-deposit",
|
|
weknora_id="remote-deposit",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
is_conversation_deposit=True,
|
|
)
|
|
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: SimpleNamespace())
|
|
monkeypatch.setattr(llmwiki_router, "_get_weknora_admin_session", fake_session)
|
|
monkeypatch.setattr(llmwiki_router, "_authorize_weknora_api_proxy", authorize)
|
|
monkeypatch.setattr(llmwiki_router.httpx, "AsyncClient", FakeAsyncClient)
|
|
|
|
response = await llmwiki_router._proxy_weknora_request(
|
|
FakeRequest(),
|
|
target_base_url="http://weknora.local",
|
|
upstream_path="/api/v1/knowledge-bases/remote-deposit",
|
|
ctx=ctx,
|
|
is_api=True,
|
|
)
|
|
|
|
payload = json.loads(response.body)
|
|
assert payload["data"] == {"summary": "Generated by cmzs", "source": "cmzs"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_iframe_proxy_blocks_other_knowledge_bases() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
class FakeRequest:
|
|
method = "GET"
|
|
headers = Headers({})
|
|
query_params = QueryParams("")
|
|
|
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="user-a",
|
|
is_admin=False,
|
|
can_write=False,
|
|
exp=4_102_444_800,
|
|
)
|
|
|
|
await llmwiki_router._authorize_weknora_api_proxy(
|
|
FakeRequest(),
|
|
ctx,
|
|
object(),
|
|
"/api/v1/knowledge-bases/remote-kb",
|
|
b"",
|
|
"token",
|
|
)
|
|
|
|
await llmwiki_router._authorize_weknora_api_proxy(
|
|
FakeRequest(),
|
|
ctx,
|
|
object(),
|
|
"/api/v1/me/invitations/pending-count",
|
|
b"",
|
|
"token",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await llmwiki_router._authorize_weknora_api_proxy(
|
|
FakeRequest(),
|
|
ctx,
|
|
object(),
|
|
"/api/v1/knowledge-bases/other-kb",
|
|
b"",
|
|
"token",
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weknora_iframe_proxy_only_reads_tenant_retrieval_config() -> None:
|
|
from app.gateway.routers import llmwiki as llmwiki_router
|
|
|
|
class FakeRequest:
|
|
headers = Headers({})
|
|
query_params = QueryParams("")
|
|
|
|
def __init__(self, method: str) -> None:
|
|
self.method = method
|
|
|
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
|
mapping_id="local-kb",
|
|
weknora_id="remote-kb",
|
|
user_id="admin-a",
|
|
is_admin=True,
|
|
can_write=True,
|
|
exp=4_102_444_800,
|
|
)
|
|
|
|
await llmwiki_router._authorize_weknora_api_proxy(
|
|
FakeRequest("GET"),
|
|
ctx,
|
|
object(),
|
|
"/api/v1/tenants/kv/retrieval-config",
|
|
b"",
|
|
"token",
|
|
)
|
|
|
|
for method, path in (
|
|
("GET", "/api/v1/tenants/kv/other-setting"),
|
|
("PUT", "/api/v1/tenants/kv/retrieval-config"),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await llmwiki_router._authorize_weknora_api_proxy(
|
|
FakeRequest(method),
|
|
ctx,
|
|
object(),
|
|
path,
|
|
b"{}",
|
|
"token",
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|