Files
2026-07-13 12:08:54 +08:00

656 lines
27 KiB
Python

"""
Integration tests for API and WebUI path prefix support via root_path.
With the root_path approach, routes always stay at their natural paths
(/docs, /health, /query, /documents/...). The api_prefix is passed to
FastAPI's root_path parameter, which controls the servers URL in the
OpenAPI spec for correct reverse proxy operation.
"""
import os
import sys
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
import pytest
# Env vars that the project's `.env` may have populated (via load_dotenv at
# import time of lightrag.api.config). Tests must be hermetic and not depend
# on developer-local .env values, so we clear/override anything that affects
# parse_args() / create_app().
_ENV_VARS_TO_ISOLATE = (
"LLM_BINDING",
"EMBEDDING_BINDING",
"LLM_BINDING_HOST",
"LLM_BINDING_API_KEY",
"LLM_MODEL",
"EMBEDDING_BINDING_HOST",
"EMBEDDING_BINDING_API_KEY",
"EMBEDDING_MODEL",
"LIGHTRAG_API_PREFIX",
"LIGHTRAG_KV_STORAGE",
"LIGHTRAG_VECTOR_STORAGE",
"LIGHTRAG_GRAPH_STORAGE",
"LIGHTRAG_DOC_STATUS_STORAGE",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Isolate tests from developer-local .env pollution.
The lightrag.api.config module loads .env at import time, which can leave
bindings/hosts/keys in os.environ that mismatch what these tests assume.
Clear them, then set the minimal viable defaults (ollama bindings) so
create_app's binding validation passes without touching real services.
"""
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
@pytest.fixture
def mock_args_api_prefix():
"""Create mock args with API prefix."""
from lightrag.api.config import parse_args
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server", "--api-prefix", "/test-api"]
args = parse_args()
yield args
finally:
sys.argv = original_argv
@pytest.fixture
def mock_args_no_prefix():
"""Create mock args without API prefix."""
from lightrag.api.config import parse_args
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
args = parse_args()
yield args
finally:
sys.argv = original_argv
class TestRootPathConfiguration:
"""Test that root_path is set correctly on the FastAPI app."""
def test_root_path_set_when_prefix_provided(self, mock_args_api_prefix):
"""Test app.root_path reflects api_prefix."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
assert app.root_path == "/test-api"
def test_root_path_none_when_no_prefix(self, mock_args_no_prefix):
"""Test app.root_path is not set when no prefix is configured."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
# When no prefix, root_path is None (not passed to FastAPI)
# FastAPI stores None as-is, which means no root_path injection
assert not app.root_path
class TestRoutesAtNaturalPaths:
"""Test that routes stay at their natural paths regardless of root_path."""
def test_routes_accessible_at_both_paths_with_prefix(self, mock_args_api_prefix):
"""With root_path, routes work at both prefixed and natural paths.
FastAPI injects root_path into the ASGI scope, and Starlette strips
it from the path before matching. So /test-api/docs and /docs both work.
"""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
# Natural path works
response = client.get("/docs")
assert response.status_code == 200
response = client.get("/openapi.json")
assert response.status_code == 200
# Prefixed path also works (FastAPI strips root_path from scope)
response = client.get("/test-api/docs")
assert response.status_code == 200
response = client.get("/test-api/openapi.json")
assert response.status_code == 200
def test_document_routes_at_natural_path(self, mock_args_api_prefix):
"""Test document routes are at /documents/ (their router-level prefix)."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
response = client.post(
"/documents/paginated",
json={},
headers={"Authorization": "Bearer test"},
)
# The route is mounted; the mocked LightRAG may cause 401/422/500,
# but a missing route (404) or wrong method (405) means routing
# itself broke and is what we want to catch here.
assert response.status_code not in (404, 405)
def test_routes_accessible_at_root_no_prefix(self, mock_args_no_prefix):
"""Test routes are at root when no prefix is set (default)."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
client = TestClient(app)
# API docs accessible at root
response = client.get("/docs")
assert response.status_code == 200
# openapi.json at root
response = client.get("/openapi.json")
assert response.status_code == 200
# Prefixed paths return 404 when no root_path is configured
response = client.get("/test-api/docs")
assert response.status_code == 404
class TestOpenAPISpecIntegration:
"""Test that OpenAPI spec uses root_path for servers URL."""
def test_openapi_spec_has_servers_url_with_prefix(self, mock_args_api_prefix):
"""Test OpenAPI spec servers URL includes the prefix via root_path."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
# OpenAPI JSON is served at the natural path
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
# Servers URL should include the prefix
servers = spec.get("servers", [])
assert len(servers) > 0, (
"OpenAPI spec should have servers entry when root_path is set"
)
assert servers[0].get("url") == "/test-api", (
f"Expected servers URL to be exactly /test-api, got: {servers[0].get('url')}"
)
def test_openapi_spec_no_servers_without_prefix(self, mock_args_no_prefix):
"""Test OpenAPI spec has no servers entry when no root_path."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
# No servers when root_path is None/empty
assert "servers" not in spec or spec["servers"] is None
def test_openapi_spec_paths_at_natural_paths(self, mock_args_api_prefix):
"""Test OpenAPI spec paths are at natural paths (not prefixed)."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
paths = spec.get("paths", {})
# Paths should be at natural paths
for path in paths:
if path == "/":
continue
assert not path.startswith("/test-api/"), (
f"Path {path} should not be prefixed with /test-api/ in root_path mode"
)
class TestWebUIPrefixIntegration:
"""Test that the WebUI is served at the expected (fixed) /webui path,
composed with `root_path` when an API prefix is set."""
def test_webui_at_prefixed_path(self, mock_args_api_prefix):
"""With root_path="/test-api" the WebUI lives at /test-api/webui/
because FastAPI injects root_path into the ASGI scope."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
response = client.get("/test-api/webui/")
assert response.status_code in [200, 307]
def test_webui_without_api_prefix(self, mock_args_no_prefix):
"""Without an API prefix the WebUI is served at /webui/."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
client = TestClient(app)
response = client.get("/webui/")
assert response.status_code in [200, 307]
class TestEnvironmentVariables:
"""Test that environment variables are read correctly."""
def test_env_api_prefix(self):
"""Test LIGHTRAG_API_PREFIX environment variable."""
from lightrag.api.config import get_env_value
os.environ["LIGHTRAG_API_PREFIX"] = "unit-test-back/api"
try:
value = get_env_value("LIGHTRAG_API_PREFIX", "")
assert value == "unit-test-back/api"
finally:
del os.environ["LIGHTRAG_API_PREFIX"]
class TestPathNormalization:
"""User input for `--api-prefix` may contain trailing slashes, a missing
leading slash, or be just '/'. create_app must canonicalize these before
passing to FastAPI's `root_path`, which doesn't accept arbitrary strings."""
def _build(self, *cli_args):
# sys.argv must be the lightrag-server form *before* lightrag_server is
# imported, because importing lightrag.api.utils_api evaluates
# `global_args.whitelist_paths` at module top level, which triggers
# parse_args() against whatever sys.argv currently holds.
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server", *cli_args]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return create_app(args)
finally:
sys.argv = original_argv
def test_api_prefix_slash_only_treated_as_empty(self):
"""`--api-prefix /` is degenerate; must collapse to no prefix."""
app = self._build("--api-prefix", "/")
assert not app.root_path
def test_api_prefix_trailing_slash_stripped(self):
"""Trailing slash on api_prefix is stripped to keep OpenAPI servers
URL clean and avoid double-slash artifacts."""
app = self._build("--api-prefix", "/api/v1/")
assert app.root_path == "/api/v1"
def test_api_prefix_missing_leading_slash_added(self):
app = self._build("--api-prefix", "api/v1")
assert app.root_path == "/api/v1"
class TestRuntimeConfigInjection:
"""End-to-end tests for the WebUI runtime-config injection.
The browser-visible URL prefixes are no longer baked into the bundle.
Instead, the server replaces a placeholder comment in index.html with
a `<script>window.__LIGHTRAG_CONFIG__ = {...}</script>` snippet on
every HTML response, so one build can serve any reverse-proxy mount.
These tests stage a minimal index.html in a tmp dir, patch
`lightrag_server.__file__` so both `check_frontend_build()` and the
static-files mount resolve to it, then drive the app via TestClient
and assert that the body contains the expected injected JSON.
"""
PLACEHOLDER = "<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->"
def _stage_index_html(self, tmp_path, *, with_placeholder=True):
"""Mirror what Vite emits: a tiny index.html with the runtime-config
placeholder in <head> plus a hashed asset reference.
with_placeholder=False simulates a stale build that pre-dates this
feature — the server should serve it untouched, not crash.
"""
webui_dir = tmp_path / "webui"
webui_dir.mkdir()
placeholder = self.PLACEHOLDER if with_placeholder else ""
(webui_dir / "index.html").write_text(
"<!doctype html><html><head>"
f"{placeholder}"
'<script type="module" crossorigin src="./assets/index-X.js"></script>'
'<link rel="stylesheet" href="./assets/index-X.css">'
"</head><body><div id=root></div></body></html>",
encoding="utf-8",
)
def _build_app(self, tmp_path, monkeypatch, *cli_args):
# Force benign argv before the (potentially fresh) module import —
# see TestPathNormalization._build for the rationale.
monkeypatch.setattr(sys, "argv", ["lightrag-server", *cli_args])
from lightrag.api.config import parse_args
from lightrag.api import lightrag_server
from lightrag.api.lightrag_server import create_app
# Redirect both check_frontend_build() and the StaticFiles mount to
# our staged tmp directory.
monkeypatch.setattr(
lightrag_server, "__file__", str(tmp_path / "lightrag_server.py")
)
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return create_app(args)
def test_injection_populates_window_config_with_prefix(self, tmp_path, monkeypatch):
"""With api_prefix=/site01, the injected script must carry both the
api prefix and the composed webui prefix the browser will see."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/site01")
client = TestClient(app)
response = client.get("/site01/webui/")
assert response.status_code == 200
body = response.text
# Placeholder must be gone and replaced with the runtime config.
assert self.PLACEHOLDER not in body
assert "window.__LIGHTRAG_CONFIG__" in body
assert '"apiPrefix": "/site01"' in body or '"apiPrefix":"/site01"' in body
assert (
'"webuiPrefix": "/site01/webui/"' in body
or '"webuiPrefix":"/site01/webui/"' in body
)
def test_injection_default_prefixes_when_unconfigured(self, tmp_path, monkeypatch):
"""No CLI flags → empty api prefix and the default webui mount.
The injected JSON must reflect this so the SPA falls through to
same-origin requests."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch)
client = TestClient(app)
response = client.get("/webui/")
assert response.status_code == 200
body = response.text
assert '"apiPrefix": ""' in body or '"apiPrefix":""' in body
assert '"webuiPrefix": "/webui/"' in body or '"webuiPrefix":"/webui/"' in body
def test_missing_placeholder_serves_original_html(self, tmp_path, monkeypatch):
"""Older builds without the placeholder must still serve cleanly —
no 500, no partial replacement, just the original HTML. Avoids
breaking anyone whose pre-built bundle is in use during an upgrade."""
self._stage_index_html(tmp_path, with_placeholder=False)
app = self._build_app(tmp_path, monkeypatch)
client = TestClient(app)
response = client.get("/webui/")
assert response.status_code == 200
# No placeholder was present, so no injected script either.
assert "window.__LIGHTRAG_CONFIG__" not in response.text
def test_injection_idempotent_across_requests(self, tmp_path, monkeypatch):
"""Each request reads the file fresh; the placeholder must be
present in the *file* even after replies (we don't mutate it)."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/abc")
client = TestClient(app)
first = client.get("/abc/webui/").text
second = client.get("/abc/webui/").text
assert first == second
# Source file untouched.
on_disk = (tmp_path / "webui" / "index.html").read_text(encoding="utf-8")
assert self.PLACEHOLDER in on_disk
def test_html_response_keeps_no_cache_headers(self, tmp_path, monkeypatch):
"""Injection must not regress the existing no-cache behaviour for
HTML — otherwise an updated runtime config could be cached client-
side and never picked up."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/x")
client = TestClient(app)
response = client.get("/x/webui/")
assert response.status_code == 200
cache_control = response.headers.get("cache-control", "")
assert "no-cache" in cache_control
assert "no-store" in cache_control
class TestUvicornRootPathSemantics:
"""Lock in the deployment contract that both proxy-strip and verbatim
forwarding work through FastAPI's app-level ``root_path`` plus a
``_RootPathNormalizationMiddleware`` — without ever passing
``root_path`` through to uvicorn/gunicorn.
Background:
- uvicorn builds ``scope["path"] = uvicorn.root_path + <incoming http
path>`` and ``scope["root_path"] = uvicorn.root_path``
(uvicorn/protocols/http/h11_impl.py).
- FastAPI's ``__call__`` overrides ``scope["root_path"]`` from
``app.root_path`` but does NOT touch ``scope["path"]``
(fastapi/applications.py:1131-1134).
- Starlette's ``Mount.matches`` mutates the child scope's root_path
to ``original + matched_path`` and again leaves ``scope["path"]``
untouched (starlette/routing.py:401-432). The inner sub-app
(e.g. StaticFiles) then sees ``scope["path"]`` and
``scope["root_path"]`` that do not overlap, and 404s.
Concrete failure mode without the middleware (proxy strips /site01,
backend sees ``/webui/``):
outer get_route_path: /webui/ does not start with /site01 → /webui/
Mount.matches: path_regex "^/webui/(?P<path>.*)$" matches
child scope.root_path = /site01/webui
child scope.path = /webui/ (unchanged)
StaticFiles.get_path: /webui/ does not start with /site01/webui →
returns /webui/ → looked up as filename
"webui" inside the webui static dir → 404
``_RootPathNormalizationMiddleware`` runs before routing and prepends
``root_path`` to ``scope["path"]`` whenever the latter does not already
start with it. This makes both modes converge to the canonical ASGI
form (path always contains root_path) without requiring uvicorn's
--root-path — which would break verbatim mode by double-prefixing.
"""
@staticmethod
async def _call_with_scope(app, http_path, *, uvicorn_root_path=""):
"""Drive the ASGI app the way uvicorn would.
Simulates uvicorn's scope construction so we can catch the
path-doubling bug that TestClient hides.
"""
full_path = uvicorn_root_path + http_path
scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
"root_path": uvicorn_root_path,
"path": full_path,
"raw_path": full_path.encode("ascii"),
"query_string": b"",
"headers": [],
"state": {},
}
status = {"code": None}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
if message["type"] == "http.response.start":
status["code"] = message["status"]
await app(scope, receive, send)
return status["code"]
def _build_app_with_prefix(self, prefix):
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server", "--api-prefix", prefix]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return create_app(args)
finally:
sys.argv = original_argv
@pytest.mark.asyncio
async def test_route_strip_mode_matches(self):
"""Plain Route, proxy-strip mode: backend receives /openapi.json."""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/openapi.json")
assert status == 200
@pytest.mark.asyncio
async def test_route_verbatim_mode_matches(self):
"""Plain Route, verbatim mode: backend receives /site01/openapi.json.
Locks in the contract that PR #3128's original fix broke: setting
uvicorn root_path would have made this case 404 by doubling the
prefix in scope["path"].
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/site01/openapi.json")
assert status == 200
@pytest.mark.asyncio
async def test_mount_strip_mode_matches(self):
"""WebUI Mount, proxy-strip mode: backend receives /webui/.
This is the bug the middleware fixes. Without normalization,
StaticFiles.get_path sees path=/webui/ and root_path=/site01/webui
(mutated by Mount.matches) and serves the literal "webui" filename
lookup → 404. With normalization, scope.path becomes
/site01/webui/ before routing and the lookup resolves to
index.html.
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/webui/")
assert status == 200
@pytest.mark.asyncio
async def test_mount_verbatim_mode_matches(self):
"""WebUI Mount, verbatim mode: backend receives /site01/webui/.
Already canonical; the middleware is a no-op for this case. The
test guards against accidentally regressing verbatim while fixing
strip — symmetric to test_route_verbatim_mode_matches but exercises
the Mount path with its nested ``get_route_path`` resolution.
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/site01/webui/")
assert status == 200
@pytest.mark.asyncio
async def test_simulated_uvicorn_root_path_breaks_verbatim(self):
"""Document the failure mode that the revert prevents.
If uvicorn's root_path were also "/site01", uvicorn would build
scope.path = "/site01" + "/site01/openapi.json". Starlette strips
one prefix; the remaining "/site01/openapi.json" has no route.
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(
app, "/site01/openapi.json", uvicorn_root_path="/site01"
)
assert status == 404
def test_launcher_does_not_pass_root_path_to_uvicorn(self, monkeypatch, tmp_path):
"""Guard against re-adding root_path to the uvicorn launcher kwargs.
Mocks uvicorn.run and exercises lightrag_server.main() far enough
to capture the config dict, then asserts root_path is absent.
"""
monkeypatch.setenv("LIGHTRAG_API_PREFIX", "/site01")
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
from lightrag.api import lightrag_server
captured = {}
def fake_run(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(lightrag_server, "uvicorn", MagicMock(run=fake_run))
monkeypatch.setattr(lightrag_server, "check_env_file", lambda: True)
monkeypatch.setattr(
lightrag_server, "check_and_install_dependencies", lambda: None
)
monkeypatch.setattr(lightrag_server, "configure_logging", lambda: None)
monkeypatch.setattr(lightrag_server, "update_uvicorn_mode_config", lambda: None)
monkeypatch.setattr(lightrag_server, "display_splash_screen", lambda *_: None)
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
# Re-parse args under the patched env so global_args picks up the prefix.
from lightrag.api.config import parse_args, global_args as _ga
new_args = parse_args()
for attr in vars(new_args):
setattr(_ga, attr, getattr(new_args, attr))
lightrag_server.main()
assert "root_path" not in captured, (
"uvicorn_config must not include root_path; rely on FastAPI's "
"app-level root_path only — see TestUvicornRootPathSemantics docstring."
)
def test_gunicorn_uses_upstream_uvicorn_worker(self):
"""Symmetric guard for the multi-worker launcher.
gunicorn_config.worker_class must remain the upstream
``uvicorn.workers.UvicornWorker`` — a custom subclass injecting
root_path via CONFIG_KWARGS would re-introduce the same
path-doubling regression in worker processes.
"""
from lightrag.api import gunicorn_config
assert gunicorn_config.worker_class == "uvicorn.workers.UvicornWorker"