chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user