test_path_prefixes.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. """
  2. Integration tests for API and WebUI path prefix support via root_path.
  3. With the root_path approach, routes always stay at their natural paths
  4. (/docs, /health, /query, /documents/...). The api_prefix is passed to
  5. FastAPI's root_path parameter, which controls the servers URL in the
  6. OpenAPI spec for correct reverse proxy operation.
  7. """
  8. import os
  9. import sys
  10. from unittest.mock import patch, MagicMock
  11. from fastapi.testclient import TestClient
  12. import pytest
  13. # Env vars that the project's `.env` may have populated (via load_dotenv at
  14. # import time of lightrag.api.config). Tests must be hermetic and not depend
  15. # on developer-local .env values, so we clear/override anything that affects
  16. # parse_args() / create_app().
  17. _ENV_VARS_TO_ISOLATE = (
  18. "LLM_BINDING",
  19. "EMBEDDING_BINDING",
  20. "LLM_BINDING_HOST",
  21. "LLM_BINDING_API_KEY",
  22. "LLM_MODEL",
  23. "EMBEDDING_BINDING_HOST",
  24. "EMBEDDING_BINDING_API_KEY",
  25. "EMBEDDING_MODEL",
  26. "LIGHTRAG_API_PREFIX",
  27. "LIGHTRAG_KV_STORAGE",
  28. "LIGHTRAG_VECTOR_STORAGE",
  29. "LIGHTRAG_GRAPH_STORAGE",
  30. "LIGHTRAG_DOC_STATUS_STORAGE",
  31. )
  32. @pytest.fixture(autouse=True)
  33. def _isolate_env(monkeypatch):
  34. """Isolate tests from developer-local .env pollution.
  35. The lightrag.api.config module loads .env at import time, which can leave
  36. bindings/hosts/keys in os.environ that mismatch what these tests assume.
  37. Clear them, then set the minimal viable defaults (ollama bindings) so
  38. create_app's binding validation passes without touching real services.
  39. """
  40. for var in _ENV_VARS_TO_ISOLATE:
  41. monkeypatch.delenv(var, raising=False)
  42. monkeypatch.setenv("LLM_BINDING", "ollama")
  43. monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
  44. @pytest.fixture
  45. def mock_args_api_prefix():
  46. """Create mock args with API prefix."""
  47. from lightrag.api.config import parse_args
  48. original_argv = sys.argv.copy()
  49. try:
  50. sys.argv = ["lightrag-server", "--api-prefix", "/test-api"]
  51. args = parse_args()
  52. yield args
  53. finally:
  54. sys.argv = original_argv
  55. @pytest.fixture
  56. def mock_args_no_prefix():
  57. """Create mock args without API prefix."""
  58. from lightrag.api.config import parse_args
  59. original_argv = sys.argv.copy()
  60. try:
  61. sys.argv = ["lightrag-server"]
  62. args = parse_args()
  63. yield args
  64. finally:
  65. sys.argv = original_argv
  66. class TestRootPathConfiguration:
  67. """Test that root_path is set correctly on the FastAPI app."""
  68. def test_root_path_set_when_prefix_provided(self, mock_args_api_prefix):
  69. """Test app.root_path reflects api_prefix."""
  70. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  71. mock_rag.return_value = MagicMock()
  72. from lightrag.api.lightrag_server import create_app
  73. app = create_app(mock_args_api_prefix)
  74. assert app.root_path == "/test-api"
  75. def test_root_path_none_when_no_prefix(self, mock_args_no_prefix):
  76. """Test app.root_path is not set when no prefix is configured."""
  77. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  78. mock_rag.return_value = MagicMock()
  79. from lightrag.api.lightrag_server import create_app
  80. app = create_app(mock_args_no_prefix)
  81. # When no prefix, root_path is None (not passed to FastAPI)
  82. # FastAPI stores None as-is, which means no root_path injection
  83. assert not app.root_path
  84. class TestRoutesAtNaturalPaths:
  85. """Test that routes stay at their natural paths regardless of root_path."""
  86. def test_routes_accessible_at_both_paths_with_prefix(self, mock_args_api_prefix):
  87. """With root_path, routes work at both prefixed and natural paths.
  88. FastAPI injects root_path into the ASGI scope, and Starlette strips
  89. it from the path before matching. So /test-api/docs and /docs both work.
  90. """
  91. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  92. mock_rag.return_value = MagicMock()
  93. from lightrag.api.lightrag_server import create_app
  94. app = create_app(mock_args_api_prefix)
  95. client = TestClient(app)
  96. # Natural path works
  97. response = client.get("/docs")
  98. assert response.status_code == 200
  99. response = client.get("/openapi.json")
  100. assert response.status_code == 200
  101. # Prefixed path also works (FastAPI strips root_path from scope)
  102. response = client.get("/test-api/docs")
  103. assert response.status_code == 200
  104. response = client.get("/test-api/openapi.json")
  105. assert response.status_code == 200
  106. def test_document_routes_at_natural_path(self, mock_args_api_prefix):
  107. """Test document routes are at /documents/ (their router-level prefix)."""
  108. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  109. mock_rag.return_value = MagicMock()
  110. from lightrag.api.lightrag_server import create_app
  111. app = create_app(mock_args_api_prefix)
  112. client = TestClient(app)
  113. response = client.post(
  114. "/documents/paginated",
  115. json={},
  116. headers={"Authorization": "Bearer test"},
  117. )
  118. # The route is mounted; the mocked LightRAG may cause 401/422/500,
  119. # but a missing route (404) or wrong method (405) means routing
  120. # itself broke and is what we want to catch here.
  121. assert response.status_code not in (404, 405)
  122. def test_routes_accessible_at_root_no_prefix(self, mock_args_no_prefix):
  123. """Test routes are at root when no prefix is set (default)."""
  124. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  125. mock_rag.return_value = MagicMock()
  126. from lightrag.api.lightrag_server import create_app
  127. app = create_app(mock_args_no_prefix)
  128. client = TestClient(app)
  129. # API docs accessible at root
  130. response = client.get("/docs")
  131. assert response.status_code == 200
  132. # openapi.json at root
  133. response = client.get("/openapi.json")
  134. assert response.status_code == 200
  135. # Prefixed paths return 404 when no root_path is configured
  136. response = client.get("/test-api/docs")
  137. assert response.status_code == 404
  138. class TestOpenAPISpecIntegration:
  139. """Test that OpenAPI spec uses root_path for servers URL."""
  140. def test_openapi_spec_has_servers_url_with_prefix(self, mock_args_api_prefix):
  141. """Test OpenAPI spec servers URL includes the prefix via root_path."""
  142. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  143. mock_rag.return_value = MagicMock()
  144. from lightrag.api.lightrag_server import create_app
  145. app = create_app(mock_args_api_prefix)
  146. client = TestClient(app)
  147. # OpenAPI JSON is served at the natural path
  148. response = client.get("/openapi.json")
  149. assert response.status_code == 200
  150. spec = response.json()
  151. # Servers URL should include the prefix
  152. servers = spec.get("servers", [])
  153. assert (
  154. len(servers) > 0
  155. ), "OpenAPI spec should have servers entry when root_path is set"
  156. assert (
  157. servers[0].get("url") == "/test-api"
  158. ), f"Expected servers URL to be exactly /test-api, got: {servers[0].get('url')}"
  159. def test_openapi_spec_no_servers_without_prefix(self, mock_args_no_prefix):
  160. """Test OpenAPI spec has no servers entry when no root_path."""
  161. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  162. mock_rag.return_value = MagicMock()
  163. from lightrag.api.lightrag_server import create_app
  164. app = create_app(mock_args_no_prefix)
  165. client = TestClient(app)
  166. response = client.get("/openapi.json")
  167. assert response.status_code == 200
  168. spec = response.json()
  169. # No servers when root_path is None/empty
  170. assert "servers" not in spec or spec["servers"] is None
  171. def test_openapi_spec_paths_at_natural_paths(self, mock_args_api_prefix):
  172. """Test OpenAPI spec paths are at natural paths (not prefixed)."""
  173. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  174. mock_rag.return_value = MagicMock()
  175. from lightrag.api.lightrag_server import create_app
  176. app = create_app(mock_args_api_prefix)
  177. client = TestClient(app)
  178. response = client.get("/openapi.json")
  179. assert response.status_code == 200
  180. spec = response.json()
  181. paths = spec.get("paths", {})
  182. # Paths should be at natural paths
  183. for path in paths:
  184. if path == "/":
  185. continue
  186. assert not path.startswith(
  187. "/test-api/"
  188. ), f"Path {path} should not be prefixed with /test-api/ in root_path mode"
  189. class TestWebUIPrefixIntegration:
  190. """Test that the WebUI is served at the expected (fixed) /webui path,
  191. composed with `root_path` when an API prefix is set."""
  192. def test_webui_at_prefixed_path(self, mock_args_api_prefix):
  193. """With root_path="/test-api" the WebUI lives at /test-api/webui/
  194. because FastAPI injects root_path into the ASGI scope."""
  195. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  196. mock_rag.return_value = MagicMock()
  197. from lightrag.api.lightrag_server import create_app
  198. app = create_app(mock_args_api_prefix)
  199. client = TestClient(app)
  200. response = client.get("/test-api/webui/")
  201. assert response.status_code in [200, 307]
  202. def test_webui_without_api_prefix(self, mock_args_no_prefix):
  203. """Without an API prefix the WebUI is served at /webui/."""
  204. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  205. mock_rag.return_value = MagicMock()
  206. from lightrag.api.lightrag_server import create_app
  207. app = create_app(mock_args_no_prefix)
  208. client = TestClient(app)
  209. response = client.get("/webui/")
  210. assert response.status_code in [200, 307]
  211. class TestEnvironmentVariables:
  212. """Test that environment variables are read correctly."""
  213. def test_env_api_prefix(self):
  214. """Test LIGHTRAG_API_PREFIX environment variable."""
  215. from lightrag.api.config import get_env_value
  216. os.environ["LIGHTRAG_API_PREFIX"] = "unit-test-back/api"
  217. try:
  218. value = get_env_value("LIGHTRAG_API_PREFIX", "")
  219. assert value == "unit-test-back/api"
  220. finally:
  221. del os.environ["LIGHTRAG_API_PREFIX"]
  222. class TestPathNormalization:
  223. """User input for `--api-prefix` may contain trailing slashes, a missing
  224. leading slash, or be just '/'. create_app must canonicalize these before
  225. passing to FastAPI's `root_path`, which doesn't accept arbitrary strings."""
  226. def _build(self, *cli_args):
  227. # sys.argv must be the lightrag-server form *before* lightrag_server is
  228. # imported, because importing lightrag.api.utils_api evaluates
  229. # `global_args.whitelist_paths` at module top level, which triggers
  230. # parse_args() against whatever sys.argv currently holds.
  231. original_argv = sys.argv.copy()
  232. try:
  233. sys.argv = ["lightrag-server", *cli_args]
  234. from lightrag.api.config import parse_args
  235. from lightrag.api.lightrag_server import create_app
  236. args = parse_args()
  237. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  238. mock_rag.return_value = MagicMock()
  239. return create_app(args)
  240. finally:
  241. sys.argv = original_argv
  242. def test_api_prefix_slash_only_treated_as_empty(self):
  243. """`--api-prefix /` is degenerate; must collapse to no prefix."""
  244. app = self._build("--api-prefix", "/")
  245. assert not app.root_path
  246. def test_api_prefix_trailing_slash_stripped(self):
  247. """Trailing slash on api_prefix is stripped to keep OpenAPI servers
  248. URL clean and avoid double-slash artifacts."""
  249. app = self._build("--api-prefix", "/api/v1/")
  250. assert app.root_path == "/api/v1"
  251. def test_api_prefix_missing_leading_slash_added(self):
  252. app = self._build("--api-prefix", "api/v1")
  253. assert app.root_path == "/api/v1"
  254. class TestRuntimeConfigInjection:
  255. """End-to-end tests for the WebUI runtime-config injection.
  256. The browser-visible URL prefixes are no longer baked into the bundle.
  257. Instead, the server replaces a placeholder comment in index.html with
  258. a `<script>window.__LIGHTRAG_CONFIG__ = {...}</script>` snippet on
  259. every HTML response, so one build can serve any reverse-proxy mount.
  260. These tests stage a minimal index.html in a tmp dir, patch
  261. `lightrag_server.__file__` so both `check_frontend_build()` and the
  262. static-files mount resolve to it, then drive the app via TestClient
  263. and assert that the body contains the expected injected JSON.
  264. """
  265. PLACEHOLDER = "<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->"
  266. def _stage_index_html(self, tmp_path, *, with_placeholder=True):
  267. """Mirror what Vite emits: a tiny index.html with the runtime-config
  268. placeholder in <head> plus a hashed asset reference.
  269. with_placeholder=False simulates a stale build that pre-dates this
  270. feature — the server should serve it untouched, not crash.
  271. """
  272. webui_dir = tmp_path / "webui"
  273. webui_dir.mkdir()
  274. placeholder = self.PLACEHOLDER if with_placeholder else ""
  275. (webui_dir / "index.html").write_text(
  276. "<!doctype html><html><head>"
  277. f"{placeholder}"
  278. '<script type="module" crossorigin src="./assets/index-X.js"></script>'
  279. '<link rel="stylesheet" href="./assets/index-X.css">'
  280. "</head><body><div id=root></div></body></html>",
  281. encoding="utf-8",
  282. )
  283. def _build_app(self, tmp_path, monkeypatch, *cli_args):
  284. # Force benign argv before the (potentially fresh) module import —
  285. # see TestPathNormalization._build for the rationale.
  286. monkeypatch.setattr(sys, "argv", ["lightrag-server", *cli_args])
  287. from lightrag.api.config import parse_args
  288. from lightrag.api import lightrag_server
  289. from lightrag.api.lightrag_server import create_app
  290. # Redirect both check_frontend_build() and the StaticFiles mount to
  291. # our staged tmp directory.
  292. monkeypatch.setattr(
  293. lightrag_server, "__file__", str(tmp_path / "lightrag_server.py")
  294. )
  295. args = parse_args()
  296. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  297. mock_rag.return_value = MagicMock()
  298. return create_app(args)
  299. def test_injection_populates_window_config_with_prefix(self, tmp_path, monkeypatch):
  300. """With api_prefix=/site01, the injected script must carry both the
  301. api prefix and the composed webui prefix the browser will see."""
  302. self._stage_index_html(tmp_path)
  303. app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/site01")
  304. client = TestClient(app)
  305. response = client.get("/site01/webui/")
  306. assert response.status_code == 200
  307. body = response.text
  308. # Placeholder must be gone and replaced with the runtime config.
  309. assert self.PLACEHOLDER not in body
  310. assert "window.__LIGHTRAG_CONFIG__" in body
  311. assert '"apiPrefix": "/site01"' in body or '"apiPrefix":"/site01"' in body
  312. assert (
  313. '"webuiPrefix": "/site01/webui/"' in body
  314. or '"webuiPrefix":"/site01/webui/"' in body
  315. )
  316. def test_injection_default_prefixes_when_unconfigured(self, tmp_path, monkeypatch):
  317. """No CLI flags → empty api prefix and the default webui mount.
  318. The injected JSON must reflect this so the SPA falls through to
  319. same-origin requests."""
  320. self._stage_index_html(tmp_path)
  321. app = self._build_app(tmp_path, monkeypatch)
  322. client = TestClient(app)
  323. response = client.get("/webui/")
  324. assert response.status_code == 200
  325. body = response.text
  326. assert '"apiPrefix": ""' in body or '"apiPrefix":""' in body
  327. assert '"webuiPrefix": "/webui/"' in body or '"webuiPrefix":"/webui/"' in body
  328. def test_missing_placeholder_serves_original_html(self, tmp_path, monkeypatch):
  329. """Older builds without the placeholder must still serve cleanly —
  330. no 500, no partial replacement, just the original HTML. Avoids
  331. breaking anyone whose pre-built bundle is in use during an upgrade."""
  332. self._stage_index_html(tmp_path, with_placeholder=False)
  333. app = self._build_app(tmp_path, monkeypatch)
  334. client = TestClient(app)
  335. response = client.get("/webui/")
  336. assert response.status_code == 200
  337. # No placeholder was present, so no injected script either.
  338. assert "window.__LIGHTRAG_CONFIG__" not in response.text
  339. def test_injection_idempotent_across_requests(self, tmp_path, monkeypatch):
  340. """Each request reads the file fresh; the placeholder must be
  341. present in the *file* even after replies (we don't mutate it)."""
  342. self._stage_index_html(tmp_path)
  343. app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/abc")
  344. client = TestClient(app)
  345. first = client.get("/abc/webui/").text
  346. second = client.get("/abc/webui/").text
  347. assert first == second
  348. # Source file untouched.
  349. on_disk = (tmp_path / "webui" / "index.html").read_text(encoding="utf-8")
  350. assert self.PLACEHOLDER in on_disk
  351. def test_html_response_keeps_no_cache_headers(self, tmp_path, monkeypatch):
  352. """Injection must not regress the existing no-cache behaviour for
  353. HTML — otherwise an updated runtime config could be cached client-
  354. side and never picked up."""
  355. self._stage_index_html(tmp_path)
  356. app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/x")
  357. client = TestClient(app)
  358. response = client.get("/x/webui/")
  359. assert response.status_code == 200
  360. cache_control = response.headers.get("cache-control", "")
  361. assert "no-cache" in cache_control
  362. assert "no-store" in cache_control
  363. class TestUvicornRootPathSemantics:
  364. """Lock in the deployment contract that both proxy-strip and verbatim
  365. forwarding work through FastAPI's app-level ``root_path`` plus a
  366. ``_RootPathNormalizationMiddleware`` — without ever passing
  367. ``root_path`` through to uvicorn/gunicorn.
  368. Background:
  369. - uvicorn builds ``scope["path"] = uvicorn.root_path + <incoming http
  370. path>`` and ``scope["root_path"] = uvicorn.root_path``
  371. (uvicorn/protocols/http/h11_impl.py).
  372. - FastAPI's ``__call__`` overrides ``scope["root_path"]`` from
  373. ``app.root_path`` but does NOT touch ``scope["path"]``
  374. (fastapi/applications.py:1131-1134).
  375. - Starlette's ``Mount.matches`` mutates the child scope's root_path
  376. to ``original + matched_path`` and again leaves ``scope["path"]``
  377. untouched (starlette/routing.py:401-432). The inner sub-app
  378. (e.g. StaticFiles) then sees ``scope["path"]`` and
  379. ``scope["root_path"]`` that do not overlap, and 404s.
  380. Concrete failure mode without the middleware (proxy strips /site01,
  381. backend sees ``/webui/``):
  382. outer get_route_path: /webui/ does not start with /site01 → /webui/
  383. Mount.matches: path_regex "^/webui/(?P<path>.*)$" matches
  384. child scope.root_path = /site01/webui
  385. child scope.path = /webui/ (unchanged)
  386. StaticFiles.get_path: /webui/ does not start with /site01/webui →
  387. returns /webui/ → looked up as filename
  388. "webui" inside the webui static dir → 404
  389. ``_RootPathNormalizationMiddleware`` runs before routing and prepends
  390. ``root_path`` to ``scope["path"]`` whenever the latter does not already
  391. start with it. This makes both modes converge to the canonical ASGI
  392. form (path always contains root_path) without requiring uvicorn's
  393. --root-path — which would break verbatim mode by double-prefixing.
  394. """
  395. @staticmethod
  396. async def _call_with_scope(app, http_path, *, uvicorn_root_path=""):
  397. """Drive the ASGI app the way uvicorn would.
  398. Simulates uvicorn's scope construction so we can catch the
  399. path-doubling bug that TestClient hides.
  400. """
  401. full_path = uvicorn_root_path + http_path
  402. scope = {
  403. "type": "http",
  404. "asgi": {"version": "3.0", "spec_version": "2.3"},
  405. "http_version": "1.1",
  406. "method": "GET",
  407. "scheme": "http",
  408. "server": ("testserver", 80),
  409. "client": ("testclient", 50000),
  410. "root_path": uvicorn_root_path,
  411. "path": full_path,
  412. "raw_path": full_path.encode("ascii"),
  413. "query_string": b"",
  414. "headers": [],
  415. "state": {},
  416. }
  417. status = {"code": None}
  418. async def receive():
  419. return {"type": "http.request", "body": b"", "more_body": False}
  420. async def send(message):
  421. if message["type"] == "http.response.start":
  422. status["code"] = message["status"]
  423. await app(scope, receive, send)
  424. return status["code"]
  425. def _build_app_with_prefix(self, prefix):
  426. original_argv = sys.argv.copy()
  427. try:
  428. sys.argv = ["lightrag-server", "--api-prefix", prefix]
  429. from lightrag.api.config import parse_args
  430. from lightrag.api.lightrag_server import create_app
  431. args = parse_args()
  432. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  433. mock_rag.return_value = MagicMock()
  434. return create_app(args)
  435. finally:
  436. sys.argv = original_argv
  437. @pytest.mark.asyncio
  438. async def test_route_strip_mode_matches(self):
  439. """Plain Route, proxy-strip mode: backend receives /openapi.json."""
  440. app = self._build_app_with_prefix("/site01")
  441. status = await self._call_with_scope(app, "/openapi.json")
  442. assert status == 200
  443. @pytest.mark.asyncio
  444. async def test_route_verbatim_mode_matches(self):
  445. """Plain Route, verbatim mode: backend receives /site01/openapi.json.
  446. Locks in the contract that PR #3128's original fix broke: setting
  447. uvicorn root_path would have made this case 404 by doubling the
  448. prefix in scope["path"].
  449. """
  450. app = self._build_app_with_prefix("/site01")
  451. status = await self._call_with_scope(app, "/site01/openapi.json")
  452. assert status == 200
  453. @pytest.mark.asyncio
  454. async def test_mount_strip_mode_matches(self):
  455. """WebUI Mount, proxy-strip mode: backend receives /webui/.
  456. This is the bug the middleware fixes. Without normalization,
  457. StaticFiles.get_path sees path=/webui/ and root_path=/site01/webui
  458. (mutated by Mount.matches) and serves the literal "webui" filename
  459. lookup → 404. With normalization, scope.path becomes
  460. /site01/webui/ before routing and the lookup resolves to
  461. index.html.
  462. """
  463. app = self._build_app_with_prefix("/site01")
  464. status = await self._call_with_scope(app, "/webui/")
  465. assert status == 200
  466. @pytest.mark.asyncio
  467. async def test_mount_verbatim_mode_matches(self):
  468. """WebUI Mount, verbatim mode: backend receives /site01/webui/.
  469. Already canonical; the middleware is a no-op for this case. The
  470. test guards against accidentally regressing verbatim while fixing
  471. strip — symmetric to test_route_verbatim_mode_matches but exercises
  472. the Mount path with its nested ``get_route_path`` resolution.
  473. """
  474. app = self._build_app_with_prefix("/site01")
  475. status = await self._call_with_scope(app, "/site01/webui/")
  476. assert status == 200
  477. @pytest.mark.asyncio
  478. async def test_simulated_uvicorn_root_path_breaks_verbatim(self):
  479. """Document the failure mode that the revert prevents.
  480. If uvicorn's root_path were also "/site01", uvicorn would build
  481. scope.path = "/site01" + "/site01/openapi.json". Starlette strips
  482. one prefix; the remaining "/site01/openapi.json" has no route.
  483. """
  484. app = self._build_app_with_prefix("/site01")
  485. status = await self._call_with_scope(
  486. app, "/site01/openapi.json", uvicorn_root_path="/site01"
  487. )
  488. assert status == 404
  489. def test_launcher_does_not_pass_root_path_to_uvicorn(self, monkeypatch, tmp_path):
  490. """Guard against re-adding root_path to the uvicorn launcher kwargs.
  491. Mocks uvicorn.run and exercises lightrag_server.main() far enough
  492. to capture the config dict, then asserts root_path is absent.
  493. """
  494. monkeypatch.setenv("LIGHTRAG_API_PREFIX", "/site01")
  495. monkeypatch.setattr(sys, "argv", ["lightrag-server"])
  496. from lightrag.api import lightrag_server
  497. captured = {}
  498. def fake_run(**kwargs):
  499. captured.update(kwargs)
  500. monkeypatch.setattr(lightrag_server, "uvicorn", MagicMock(run=fake_run))
  501. monkeypatch.setattr(lightrag_server, "check_env_file", lambda: True)
  502. monkeypatch.setattr(
  503. lightrag_server, "check_and_install_dependencies", lambda: None
  504. )
  505. monkeypatch.setattr(lightrag_server, "configure_logging", lambda: None)
  506. monkeypatch.setattr(lightrag_server, "update_uvicorn_mode_config", lambda: None)
  507. monkeypatch.setattr(lightrag_server, "display_splash_screen", lambda *_: None)
  508. with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
  509. mock_rag.return_value = MagicMock()
  510. # Re-parse args under the patched env so global_args picks up the prefix.
  511. from lightrag.api.config import parse_args, global_args as _ga
  512. new_args = parse_args()
  513. for attr in vars(new_args):
  514. setattr(_ga, attr, getattr(new_args, attr))
  515. lightrag_server.main()
  516. assert "root_path" not in captured, (
  517. "uvicorn_config must not include root_path; rely on FastAPI's "
  518. "app-level root_path only — see TestUvicornRootPathSemantics docstring."
  519. )
  520. def test_gunicorn_uses_upstream_uvicorn_worker(self):
  521. """Symmetric guard for the multi-worker launcher.
  522. gunicorn_config.worker_class must remain the upstream
  523. ``uvicorn.workers.UvicornWorker`` — a custom subclass injecting
  524. root_path via CONFIG_KWARGS would re-introduce the same
  525. path-doubling regression in worker processes.
  526. """
  527. from lightrag.api import gunicorn_config
  528. assert gunicorn_config.worker_class == "uvicorn.workers.UvicornWorker"