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=('
'), 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'fetch("/api/v1/auth/me")' 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