chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
View File
View File
+272
View File
@@ -0,0 +1,272 @@
import importlib
import sys
from types import SimpleNamespace
import bcrypt
import pytest
from fastapi import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from lightrag.api.passwords import BCRYPT_PASSWORD_PREFIX, hash_password
from lightrag.tools.hash_password import main as hash_password_main
from lightrag.utils import logger as lightrag_logger
def import_real_api_module(module_name: str):
sys.modules.pop(module_name, None)
package_name, _, child_name = module_name.rpartition(".")
package = sys.modules.get(package_name)
if package is not None and hasattr(package, child_name):
delattr(package, child_name)
return importlib.import_module(module_name)
@pytest.fixture
def auth_module(monkeypatch):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret="test-jwt-secret",
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="admin:admin_pass",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
module = import_real_api_module("lightrag.api.auth")
module = importlib.reload(module)
yield module
sys.modules.pop("lightrag.api.auth", None)
def build_bcrypt_value(password: str) -> str:
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"
def test_verify_plaintext_password(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"admin": "admin_pass"}
assert handler.verify_password("admin", "admin_pass")
assert not handler.verify_password("admin", "wrong_pass")
def test_verify_prefixed_bcrypt_password(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"user": build_bcrypt_value("user_pass")}
assert handler.verify_password("user", "user_pass")
assert not handler.verify_password("user", "wrong_pass")
def test_plaintext_password_with_bcrypt_prefix_stays_plaintext(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"user": "$2b$not-a-real-hash"}
assert handler.verify_password("user", "$2b$not-a-real-hash")
assert not handler.verify_password("user", "anything-else")
def test_invalid_auth_accounts_raises(monkeypatch):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret="test-jwt-secret",
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="admin",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
with pytest.raises(ValueError, match="AUTH_ACCOUNTS must use"):
import_real_api_module("lightrag.api.auth")
sys.modules.pop("lightrag.api.auth", None)
def test_initialize_config_rejects_default_token_secret_with_auth_accounts():
config = import_real_api_module("lightrag.api.config")
insecure_args = SimpleNamespace(
auth_accounts="admin:admin_pass",
token_secret=config.DEFAULT_TOKEN_SECRET,
)
with pytest.raises(ValueError, match="TOKEN_SECRET must be explicitly set"):
config.initialize_config(insecure_args, force=True)
def test_initialize_config_allows_custom_token_secret_with_auth_accounts():
config = import_real_api_module("lightrag.api.config")
secure_args = SimpleNamespace(
auth_accounts="admin:admin_pass",
token_secret="custom-jwt-secret",
)
initialized = config.initialize_config(secure_args, force=True)
assert initialized is secure_args
def test_guest_tokens_fall_back_to_default_secret_when_token_secret_missing(
monkeypatch,
):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret=None,
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
warning_messages = []
def capture_warning(message):
warning_messages.append(message)
monkeypatch.setattr(lightrag_logger, "warning", capture_warning)
module = import_real_api_module("lightrag.api.auth")
module = importlib.reload(module)
handler = module.AuthHandler()
token = handler.create_token("guest", role="guest")
token_info = handler.validate_token(token)
assert handler.secret == config.DEFAULT_TOKEN_SECRET
assert token_info["username"] == "guest"
assert token_info["role"] == "guest"
assert any(
"Falling back to the default guest-mode JWT secret" in msg
for msg in warning_messages
)
sys.modules.pop("lightrag.api.auth", None)
def _load_api_key_only_modules(monkeypatch):
"""Import auth + utils_api in the API-key-only profile.
API-key-only = an API key is set but AUTH_ACCOUNTS is empty, so
``auth_configured`` is False and AuthHandler falls back to the public
DEFAULT_TOKEN_SECRET. Returns (config, auth, utils_api).
"""
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret=None, # -> AuthHandler falls back to DEFAULT_TOKEN_SECRET
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="", # no password accounts -> auth_configured is False
whitelist_paths="/health", # consumed by utils_api at import time
token_auto_renew=False,
)
monkeypatch.setattr(config, "global_args", mock_global_args)
auth = import_real_api_module("lightrag.api.auth")
auth = importlib.reload(auth)
utils_api = import_real_api_module("lightrag.api.utils_api")
return config, auth, utils_api
async def test_combined_auth_rejects_guest_token_in_api_key_mode(monkeypatch):
"""A forged/obtained guest token must not bypass the X-API-Key check.
Regression for GHSA-f4vv-55c2-5789 / GHSA-xr5c-v5r6-c9f9: in the
API-key-only profile an anonymous caller can mint a guest JWT with the
public default secret (or fetch one from /auth-status) and previously
short-circuited authorization before the API key was ever checked.
"""
config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
try:
assert utils_api.auth_configured is False
assert auth.auth_handler.secret == config.DEFAULT_TOKEN_SECRET
forged_guest = auth.auth_handler.create_token(username="guest", role="guest")
api_key = "operator-secret-key"
dependency = utils_api.get_combined_auth_dependency(api_key=api_key)
request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
response = SimpleNamespace(headers={})
# Forged guest token, no API key -> rejected (was HTTP 200 before the fix).
with pytest.raises(HTTPException) as rejected:
await dependency(
request=request,
response=response,
token=forged_guest,
api_key_header_value=None,
)
assert rejected.value.status_code == HTTP_403_FORBIDDEN
# Guest token together with the real API key -> allowed (the WebUI sends
# both Authorization and X-API-Key headers).
result = await dependency(
request=request,
response=response,
token=forged_guest,
api_key_header_value=api_key,
)
assert result is None
finally:
sys.modules.pop("lightrag.api.utils_api", None)
sys.modules.pop("lightrag.api.auth", None)
async def test_combined_auth_allows_guest_token_when_fully_open(monkeypatch):
"""Fully-open mode (no API key, no AUTH_ACCOUNTS) keeps accepting guest tokens.
Guards against over-fixing: the bypass fix must not break zero-config guest
access, which is the intended convenience mode.
"""
_config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
try:
guest_token = auth.auth_handler.create_token(username="guest", role="guest")
dependency = utils_api.get_combined_auth_dependency(api_key=None)
request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
response = SimpleNamespace(headers={})
result = await dependency(
request=request,
response=response,
token=guest_token,
api_key_header_value=None,
)
assert result is None
finally:
sys.modules.pop("lightrag.api.utils_api", None)
sys.modules.pop("lightrag.api.auth", None)
def test_hash_password_returns_prefixed_value(auth_module):
hashed = hash_password("new_password")
assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
assert bcrypt.checkpw("new_password".encode("utf-8"), raw_hash.encode("utf-8"))
def test_hash_password_cli_outputs_auth_accounts_entry(capsys):
exit_code = hash_password_main(["--username", "admin", "secret"])
assert exit_code == 0
output = capsys.readouterr().out.strip()
username, hashed = output.split(":", 1)
assert username == "admin"
assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
assert bcrypt.checkpw("secret".encode("utf-8"), raw_hash.encode("utf-8"))
+403
View File
@@ -0,0 +1,403 @@
"""
Pytest unit tests for token auto-renewal functionality
Tests:
1. Backend token renewal logic
2. Rate limiting for token renewals
3. Token renewal state tracking
"""
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import Mock
from fastapi import Response
import time
# Create a simple token renewal cache for testing
_token_renewal_cache = {}
_RENEWAL_MIN_INTERVAL = 60
@pytest.mark.offline
class TestTokenRenewal:
"""Tests for token auto-renewal logic"""
@pytest.fixture
def mock_auth_handler(self):
"""Mock authentication handler"""
handler = Mock()
handler.guest_expire_hours = 24
handler.expire_hours = 24
handler.create_token = Mock(return_value="new-token-12345")
return handler
@pytest.fixture
def mock_global_args(self):
"""Mock global configuration"""
args = Mock()
args.token_auto_renew = True
args.token_renew_threshold = 0.5
return args
@pytest.fixture
def mock_token_info_guest(self):
"""Mock token info for guest user"""
# Token with 10 hours remaining (below 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
return {
"username": "guest",
"role": "guest",
"exp": exp_time,
"metadata": {"auth_mode": "disabled"},
}
@pytest.fixture
def mock_token_info_user(self):
"""Mock token info for regular user"""
# Token with 10 hours remaining (below 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
return {
"username": "testuser",
"role": "user",
"exp": exp_time,
"metadata": {"auth_mode": "enabled"},
}
@pytest.fixture
def mock_token_info_above_threshold(self):
"""Mock token info with time above renewal threshold"""
# Token with 20 hours remaining (above 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=20)
return {
"username": "testuser",
"role": "user",
"exp": exp_time,
"metadata": {"auth_mode": "enabled"},
}
def test_token_renewal_when_below_threshold(
self, mock_auth_handler, mock_global_args, mock_token_info_user
):
"""Test that token is renewed when remaining time < threshold"""
# Use global cache
global _token_renewal_cache
# Clear cache
_token_renewal_cache.clear()
response = Mock(spec=Response)
response.headers = {}
# Simulate the renewal logic
expire_time = mock_token_info_user["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
role = mock_token_info_user["role"]
total_hours = (
mock_auth_handler.expire_hours
if role == "user"
else mock_auth_handler.guest_expire_hours
)
total_seconds = total_hours * 3600
# Should renew because remaining_seconds < total_seconds * 0.5
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is True
# Simulate renewal
username = mock_token_info_user["username"]
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time - last_renewal
# Should pass rate limit (first renewal)
assert time_since_last_renewal >= 60 or last_renewal == 0
# Perform renewal
new_token = mock_auth_handler.create_token(
username=username, role=role, metadata=mock_token_info_user["metadata"]
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = current_time
# Verify
assert "X-New-Token" in response.headers
assert response.headers["X-New-Token"] == "new-token-12345"
assert username in _token_renewal_cache
def test_token_no_renewal_when_above_threshold(
self, mock_auth_handler, mock_global_args, mock_token_info_above_threshold
):
"""Test that token is NOT renewed when remaining time > threshold"""
response = Mock(spec=Response)
response.headers = {}
expire_time = mock_token_info_above_threshold["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
mock_token_info_above_threshold["role"]
total_hours = mock_auth_handler.expire_hours
total_seconds = total_hours * 3600
# Should NOT renew because remaining_seconds > total_seconds * 0.5
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is False
# No renewal should happen
assert "X-New-Token" not in response.headers
def test_token_renewal_disabled(
self, mock_auth_handler, mock_global_args, mock_token_info_user
):
"""Test that no renewal happens when TOKEN_AUTO_RENEW=false"""
mock_global_args.token_auto_renew = False
response = Mock(spec=Response)
response.headers = {}
# Auto-renewal is disabled, so even if below threshold, no renewal
if not mock_global_args.token_auto_renew:
# Skip renewal logic
pass
assert "X-New-Token" not in response.headers
def test_token_renewal_for_guest_mode(
self, mock_auth_handler, mock_global_args, mock_token_info_guest
):
"""Test that guest tokens are renewed correctly"""
# Use global cache
global _token_renewal_cache
_token_renewal_cache.clear()
response = Mock(spec=Response)
response.headers = {}
expire_time = mock_token_info_guest["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
role = mock_token_info_guest["role"]
total_hours = mock_auth_handler.guest_expire_hours
total_seconds = total_hours * 3600
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is True
# Renewal for guest
username = mock_token_info_guest["username"]
new_token = mock_auth_handler.create_token(
username=username, role=role, metadata=mock_token_info_guest["metadata"]
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = time.time()
assert "X-New-Token" in response.headers
assert username in _token_renewal_cache
@pytest.mark.offline
class TestRateLimiting:
"""Tests for token renewal rate limiting"""
@pytest.fixture
def mock_auth_handler(self):
"""Mock authentication handler"""
handler = Mock()
handler.expire_hours = 24
handler.create_token = Mock(return_value="new-token-12345")
return handler
def test_rate_limit_prevents_rapid_renewals(self, mock_auth_handler):
"""Test that second renewal within 60s is blocked"""
# Use global cache and constant
global _token_renewal_cache, _RENEWAL_MIN_INTERVAL
username = "testuser"
_token_renewal_cache.clear()
# First renewal
current_time_1 = time.time()
_token_renewal_cache[username] = current_time_1
response_1 = Mock(spec=Response)
response_1.headers = {}
response_1.headers["X-New-Token"] = "new-token-12345"
# Immediate second renewal attempt (within 60s)
current_time_2 = time.time() # Almost same time
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time_2 - last_renewal
# Should be blocked by rate limit
assert time_since_last_renewal < _RENEWAL_MIN_INTERVAL
response_2 = Mock(spec=Response)
response_2.headers = {}
# No new token should be issued
if time_since_last_renewal < _RENEWAL_MIN_INTERVAL:
# Rate limited, skip renewal
pass
assert "X-New-Token" not in response_2.headers
def test_rate_limit_allows_renewal_after_interval(self, mock_auth_handler):
"""Test that renewal succeeds after 60s interval"""
# Use global cache and constant
global _token_renewal_cache, _RENEWAL_MIN_INTERVAL
username = "testuser"
_token_renewal_cache.clear()
# First renewal at time T
first_renewal_time = time.time() - 61 # 61 seconds ago
_token_renewal_cache[username] = first_renewal_time
# Second renewal attempt now
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time - last_renewal
# Should pass rate limit (>60s elapsed)
assert time_since_last_renewal >= _RENEWAL_MIN_INTERVAL
response = Mock(spec=Response)
response.headers = {}
if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:
new_token = mock_auth_handler.create_token(
username=username, role="user", metadata={}
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = current_time
assert "X-New-Token" in response.headers
assert response.headers["X-New-Token"] == "new-token-12345"
def test_rate_limit_per_user(self, mock_auth_handler):
"""Test that different users have independent rate limits"""
# Use global cache
global _token_renewal_cache
_token_renewal_cache.clear()
user1 = "user1"
user2 = "user2"
current_time = time.time()
# User1 gets renewal
_token_renewal_cache[user1] = current_time
# User2 should still be able to get renewal (independent cache)
last_renewal_user2 = _token_renewal_cache.get(user2, 0)
assert last_renewal_user2 == 0 # No previous renewal
# User2 can renew
_token_renewal_cache[user2] = current_time
# Both users should have entries
assert user1 in _token_renewal_cache
assert user2 in _token_renewal_cache
assert _token_renewal_cache[user1] == _token_renewal_cache[user2]
@pytest.mark.offline
class TestTokenExpirationCalculation:
"""Tests for token expiration time calculation"""
def test_expiration_extraction_from_jwt(self):
"""Test extracting expiration time from JWT token"""
import base64
import json
# Create a mock JWT payload
exp_timestamp = int(
(datetime.now(timezone.utc) + timedelta(hours=24)).timestamp()
)
payload = {"sub": "testuser", "role": "user", "exp": exp_timestamp}
# Encode as base64 (simulating JWT structure: header.payload.signature)
payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode()
mock_token = f"header.{payload_b64}.signature"
# Simulate extraction
parts = mock_token.split(".")
assert len(parts) == 3
decoded_payload = json.loads(base64.b64decode(parts[1]))
assert decoded_payload["exp"] == exp_timestamp
assert decoded_payload["sub"] == "testuser"
def test_remaining_time_calculation(self):
"""Test calculation of remaining token time"""
# Token expires in 10 hours
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
now = datetime.now(timezone.utc)
remaining_seconds = (exp_time - now).total_seconds()
# Should be approximately 10 hours (36000 seconds)
assert 35990 < remaining_seconds < 36010
# Calculate percentage remaining (for 24-hour token)
total_seconds = 24 * 3600
percentage_remaining = remaining_seconds / total_seconds
# Should be approximately 41.67% remaining
assert 0.41 < percentage_remaining < 0.42
def test_threshold_comparison(self):
"""Test threshold-based renewal decision"""
threshold = 0.5
total_hours = 24
total_seconds = total_hours * 3600
# Scenario 1: 10 hours remaining -> should renew
remaining_seconds_1 = 10 * 3600
should_renew_1 = remaining_seconds_1 < total_seconds * threshold
assert should_renew_1 is True
# Scenario 2: 20 hours remaining -> should NOT renew
remaining_seconds_2 = 20 * 3600
should_renew_2 = remaining_seconds_2 < total_seconds * threshold
assert should_renew_2 is False
# Scenario 3: Exactly 12 hours remaining (at threshold) -> should NOT renew
remaining_seconds_3 = 12 * 3600
should_renew_3 = remaining_seconds_3 < total_seconds * threshold
assert should_renew_3 is False
@pytest.mark.offline
def test_renewal_cache_cleanup():
"""Test that renewal cache can be cleared"""
# Use global cache
global _token_renewal_cache
# Clear first
_token_renewal_cache.clear()
# Add some entries
_token_renewal_cache["user1"] = time.time()
_token_renewal_cache["user2"] = time.time()
assert len(_token_renewal_cache) == 2
# Clear cache
_token_renewal_cache.clear()
assert len(_token_renewal_cache) == 0
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
View File
+141
View File
@@ -0,0 +1,141 @@
import sys
import pytest
from lightrag.api.config import get_default_host, parse_args
pytestmark = pytest.mark.offline
def _clear_bedrock_auth_env(monkeypatch):
for key in (
"LLM_BINDING",
"EMBEDDING_BINDING",
"QUERY_LLM_BINDING",
"QUERY_LLM_BINDING_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK",
"QUERY_AWS_ACCESS_KEY_ID",
"QUERY_AWS_SECRET_ACCESS_KEY",
):
monkeypatch.delenv(key, raising=False)
def test_bedrock_default_host_uses_sdk_default_endpoint(monkeypatch):
monkeypatch.delenv("LLM_BINDING_HOST", raising=False)
assert get_default_host("bedrock") == "DEFAULT_BEDROCK_ENDPOINT"
def test_bedrock_custom_host_is_returned(monkeypatch):
monkeypatch.setenv("LLM_BINDING_HOST", "https://proxy.example.com")
assert get_default_host("bedrock") == "https://proxy.example.com"
def test_bedrock_env_binding_alias_is_normalized(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "aws_bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "aws_bedrock")
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "absk-test")
args = parse_args()
assert args.llm_binding == "bedrock"
assert args.embedding_binding == "bedrock"
def test_bedrock_cli_binding_alias_is_not_supported(monkeypatch):
monkeypatch.setattr(
sys, "argv", ["lightrag-server", "--llm-binding", "aws_bedrock"]
)
with pytest.raises(SystemExit):
parse_args()
def test_bedrock_role_env_binding_alias_is_normalized(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("QUERY_LLM_BINDING", "aws_bedrock")
monkeypatch.setenv("QUERY_LLM_MODEL", "us.amazon.nova-lite-v1:0")
monkeypatch.setenv("QUERY_AWS_REGION", "us-west-2")
monkeypatch.setenv("QUERY_AWS_ACCESS_KEY_ID", "akid")
monkeypatch.setenv("QUERY_AWS_SECRET_ACCESS_KEY", "secret")
args = parse_args()
assert args.query_llm_binding == "bedrock"
assert args.query_aws_region == "us-west-2"
def test_bedrock_role_api_key_is_rejected(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("QUERY_LLM_BINDING", "bedrock")
monkeypatch.setenv("QUERY_LLM_MODEL", "us.amazon.nova-lite-v1:0")
monkeypatch.setenv("QUERY_LLM_BINDING_API_KEY", "absk-role")
with pytest.raises(SystemExit, match="does not support QUERY_LLM_BINDING_API_KEY"):
parse_args()
def test_bedrock_binding_requires_sigv4_pair_or_bearer_token(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
with pytest.raises(ValueError, match="Bedrock LLM binding requires"):
parse_args()
def test_bedrock_binding_rejects_partial_sigv4_pair(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid")
with pytest.raises(ValueError, match="Bedrock LLM binding requires"):
parse_args()
def test_bedrock_binding_accepts_sigv4_pair(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret")
args = parse_args()
assert args.llm_binding == "bedrock"
def test_bedrock_binding_accepts_bearer_token(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "absk-test")
args = parse_args()
assert args.llm_binding == "bedrock"
def test_bedrock_role_requires_complete_role_sigv4_pair(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("QUERY_LLM_BINDING", "bedrock")
monkeypatch.setenv("QUERY_LLM_MODEL", "us.amazon.nova-lite-v1:0")
monkeypatch.setenv("QUERY_AWS_ACCESS_KEY_ID", "akid")
with pytest.raises(ValueError, match="Bedrock role 'query' requires"):
parse_args()
@@ -0,0 +1,12 @@
import pytest
from lightrag.api.config import get_default_host
pytestmark = pytest.mark.offline
def test_gemini_default_host_uses_sdk_default_endpoint(monkeypatch):
monkeypatch.delenv("LLM_BINDING_HOST", raising=False)
assert get_default_host("gemini") == "DEFAULT_GEMINI_ENDPOINT"
@@ -0,0 +1,95 @@
import sys
import pytest
from lightrag.api.config import parse_args
from lightrag.constants import DEFAULT_MAX_ASYNC
pytestmark = pytest.mark.offline
ROLE_MAX_ASYNC_ENV_KEYS = (
"MAX_ASYNC_LLM",
"MAX_ASYNC",
"EXTRACT_MAX_ASYNC_LLM",
"KEYWORD_MAX_ASYNC_LLM",
"QUERY_MAX_ASYNC_LLM",
"VLM_MAX_ASYNC_LLM",
)
def _clear_max_async_env(monkeypatch):
for key in ROLE_MAX_ASYNC_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
def test_role_max_async_defaults_none_when_env_unset(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC", "10")
args = parse_args()
assert args.max_async == 10
assert args.extract_llm_max_async is None
assert args.keyword_llm_max_async is None
assert args.query_llm_max_async is None
assert args.vlm_llm_max_async is None
def test_role_max_async_env_override_keeps_other_roles_none(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC", "10")
monkeypatch.setenv("EXTRACT_MAX_ASYNC_LLM", "7")
args = parse_args()
assert args.max_async == 10
assert args.extract_llm_max_async == 7
assert args.keyword_llm_max_async is None
assert args.query_llm_max_async is None
assert args.vlm_llm_max_async is None
def test_role_max_async_literal_none_string_is_preserved(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC", "10")
monkeypatch.setenv("QUERY_MAX_ASYNC_LLM", "None")
args = parse_args()
assert args.max_async == 10
assert args.query_llm_max_async is None
def test_max_async_llm_new_name(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC_LLM", "8")
args = parse_args()
assert args.max_async == 8
def test_max_async_llm_takes_precedence_over_legacy(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC_LLM", "8")
monkeypatch.setenv("MAX_ASYNC", "10")
args = parse_args()
assert args.max_async == 8
def test_max_async_defaults_when_unset(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
args = parse_args()
assert args.max_async == DEFAULT_MAX_ASYNC
@@ -0,0 +1,111 @@
"""Offline tests for VLM_PROCESS_ENABLE and renamed role timeout vars."""
from __future__ import annotations
import sys
import pytest
from lightrag.api.config import parse_args
pytestmark = pytest.mark.offline
def _reset_vlm_env(monkeypatch):
for key in (
"VLM_PROCESS_ENABLE",
"VLM_LLM_BINDING",
"VLM_LLM_MODEL",
"VLM_LLM_BINDING_HOST",
"VLM_LLM_BINDING_API_KEY",
"VLM_LLM_TIMEOUT",
"EXTRACT_LLM_TIMEOUT",
"KEYWORD_LLM_TIMEOUT",
"QUERY_LLM_TIMEOUT",
"LLM_TIMEOUT_VLM_LLM",
"LLM_TIMEOUT_EXTRACT_LLM",
"LLM_TIMEOUT_KEYWORD_LLM",
"LLM_TIMEOUT_QUERY_LLM",
):
monkeypatch.delenv(key, raising=False)
def test_vlm_process_enable_defaults_to_false(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
args = parse_args()
assert args.vlm_process_enable is False
def test_vlm_process_enable_true_with_openai_passes(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("VLM_PROCESS_ENABLE", "true")
args = parse_args()
assert args.vlm_process_enable is True
def test_vlm_process_enable_rejects_lollms_base_binding(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "lollms")
monkeypatch.setenv("VLM_PROCESS_ENABLE", "true")
with pytest.raises(SystemExit) as exc:
parse_args()
assert "lollms" in str(exc.value).lower()
def test_vlm_process_enable_rejects_lollms_role_binding(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("VLM_PROCESS_ENABLE", "true")
monkeypatch.setenv("VLM_LLM_BINDING", "lollms")
# Cross-provider validation needs model + key; fill them so the lollms
# branch is the only failure path.
monkeypatch.setenv("VLM_LLM_MODEL", "anything")
monkeypatch.setenv("VLM_LLM_BINDING_HOST", "http://localhost:9600")
monkeypatch.setenv("VLM_LLM_BINDING_API_KEY", "placeholder")
with pytest.raises(SystemExit) as exc:
parse_args()
assert "lollms" in str(exc.value).lower()
def test_role_timeout_uses_new_variable_names(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("EXTRACT_LLM_TIMEOUT", "240")
monkeypatch.setenv("KEYWORD_LLM_TIMEOUT", "120")
monkeypatch.setenv("QUERY_LLM_TIMEOUT", "60")
monkeypatch.setenv("VLM_LLM_TIMEOUT", "300")
args = parse_args()
assert args.extract_llm_timeout == 240
assert args.keyword_llm_timeout == 120
assert args.query_llm_timeout == 60
assert args.vlm_llm_timeout == 300
def test_role_timeout_legacy_variables_no_longer_have_effect(monkeypatch):
"""The breaking-change migration: legacy LLM_TIMEOUT_{ROLE}_LLM is silently ignored."""
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("LLM_TIMEOUT_EXTRACT_LLM", "999")
monkeypatch.setenv("LLM_TIMEOUT_VLM_LLM", "888")
args = parse_args()
assert args.extract_llm_timeout is None
assert args.vlm_llm_timeout is None
@@ -0,0 +1,80 @@
"""Offline tests for whitelist_exposes_api_routes (GHSA-mmg5-8x8q-v934 banner).
The startup banner uses this helper to warn when WHITELIST_PATHS leaves the
Ollama-compatible /api/* routes unauthenticated on a network-exposed bind. It
must mirror the prefix/exact matching in get_combined_auth_dependency so a
catch-all entry like "/*" is detected, not just literal "/api..." entries.
"""
from __future__ import annotations
import importlib
import sys
from types import SimpleNamespace
import pytest
pytestmark = pytest.mark.offline
@pytest.fixture
def whitelist_exposes_api_routes(monkeypatch):
"""Import utils_api with a stub global_args (consumed at import time)."""
config = importlib.import_module("lightrag.api.config")
monkeypatch.setattr(
config,
"global_args",
SimpleNamespace(
token_secret=None, # -> AuthHandler falls back to DEFAULT_TOKEN_SECRET
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="",
whitelist_paths="/health", # consumed by utils_api at import time
token_auto_renew=False,
),
)
sys.modules.pop("lightrag.api.auth", None)
importlib.reload(importlib.import_module("lightrag.api.auth"))
sys.modules.pop("lightrag.api.utils_api", None)
utils_api = importlib.import_module("lightrag.api.utils_api")
try:
yield utils_api.whitelist_exposes_api_routes
finally:
sys.modules.pop("lightrag.api.utils_api", None)
@pytest.mark.parametrize(
"whitelist",
[
"/health,/api/*", # default whitelist
"/api/*",
"/api",
"/api/chat", # exact Ollama route
"/*", # catch-all: empty prefix matches every path
" /* ", # catch-all with surrounding whitespace
"/health,/*",
"/a/*", # prefix that /api also starts with
],
)
def test_exposes_api_routes(whitelist_exposes_api_routes, whitelist: str) -> None:
assert whitelist_exposes_api_routes(whitelist) is True
@pytest.mark.parametrize(
"whitelist",
[
"/health",
"/health,/docs",
"/health/*", # unrelated prefix
"/apiary/*", # prefix under /api... but not /api/ — exempts only /apiary
"/apidocs", # exact path that merely shares the /api substring
"",
" ",
],
)
def test_does_not_expose_api_routes(
whitelist_exposes_api_routes, whitelist: str
) -> None:
assert whitelist_exposes_api_routes(whitelist) is False
View File
@@ -0,0 +1,781 @@
#!/usr/bin/env python3
"""
Test script: Demonstrates usage of aquery_data FastAPI endpoint
Query content: Who is the author of LightRAG
Updated to handle the new data format where:
- Response includes status, message, data, and metadata fields at top level
- Actual query results (entities, relationships, chunks, references) are nested under 'data' field
- Includes backward compatibility with legacy format
"""
import pytest
import requests
import time
import json
from typing import Dict, Any, List, Optional
# API configuration
API_KEY = "your-secure-api-key-here-123"
BASE_URL = "http://localhost:9621"
# Unified authentication headers
AUTH_HEADERS = {"Content-Type": "application/json", "X-API-Key": API_KEY}
def validate_references_format(references: List[Dict[str, Any]]) -> bool:
"""Validate the format of references list"""
if not isinstance(references, list):
print(f"❌ References should be a list, got {type(references)}")
return False
for i, ref in enumerate(references):
if not isinstance(ref, dict):
print(f"❌ Reference {i} should be a dict, got {type(ref)}")
return False
required_fields = ["reference_id", "file_path"]
for field in required_fields:
if field not in ref:
print(f"❌ Reference {i} missing required field: {field}")
return False
if not isinstance(ref[field], str):
print(
f"❌ Reference {i} field '{field}' should be string, got {type(ref[field])}"
)
return False
return True
def parse_streaming_response(
response_text: str,
) -> tuple[Optional[List[Dict]], List[str], List[str]]:
"""Parse streaming response and extract references, response chunks, and errors"""
references = None
response_chunks = []
errors = []
lines = response_text.strip().split("\n")
for line in lines:
line = line.strip()
if not line or line.startswith("data: "):
if line.startswith("data: "):
line = line[6:] # Remove 'data: ' prefix
if not line:
continue
try:
data = json.loads(line)
if "references" in data:
references = data["references"]
if "response" in data:
response_chunks.append(data["response"])
if "error" in data:
errors.append(data["error"])
except json.JSONDecodeError:
# Skip non-JSON lines (like SSE comments)
continue
return references, response_chunks, errors
@pytest.mark.integration
@pytest.mark.requires_api
def test_query_endpoint_references():
"""Test /query endpoint references functionality"""
print("\n" + "=" * 60)
print("Testing /query endpoint references functionality")
print("=" * 60)
query_text = "who authored LightRAG"
endpoint = f"{BASE_URL}/query"
# Test 1: References enabled (default)
print("\n🧪 Test 1: References enabled (default)")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": True},
headers=AUTH_HEADERS,
timeout=30,
)
if response.status_code == 200:
data = response.json()
# Check response structure
if "response" not in data:
print("❌ Missing 'response' field")
return False
if "references" not in data:
print("❌ Missing 'references' field when include_references=True")
return False
references = data["references"]
if references is None:
print("❌ References should not be None when include_references=True")
return False
if not validate_references_format(references):
return False
print(f"✅ References enabled: Found {len(references)} references")
print(f" Response length: {len(data['response'])} characters")
# Display reference list
if references:
print(" 📚 Reference List:")
for i, ref in enumerate(references, 1):
ref_id = ref.get("reference_id", "Unknown")
file_path = ref.get("file_path", "Unknown")
print(f" {i}. ID: {ref_id} | File: {file_path}")
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 1 failed: {str(e)}")
return False
# Test 2: References disabled
print("\n🧪 Test 2: References disabled")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": False},
headers=AUTH_HEADERS,
timeout=30,
)
if response.status_code == 200:
data = response.json()
# Check response structure
if "response" not in data:
print("❌ Missing 'response' field")
return False
references = data.get("references")
if references is not None:
print("❌ References should be None when include_references=False")
return False
print("✅ References disabled: No references field present")
print(f" Response length: {len(data['response'])} characters")
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 2 failed: {str(e)}")
return False
print("\n✅ /query endpoint references tests passed!")
return True
@pytest.mark.integration
@pytest.mark.requires_api
def test_query_stream_endpoint_references():
"""Test /query/stream endpoint references functionality"""
print("\n" + "=" * 60)
print("Testing /query/stream endpoint references functionality")
print("=" * 60)
query_text = "who authored LightRAG"
endpoint = f"{BASE_URL}/query/stream"
# Test 1: Streaming with references enabled
print("\n🧪 Test 1: Streaming with references enabled")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": True},
headers=AUTH_HEADERS,
timeout=30,
stream=True,
)
if response.status_code == 200:
# Collect streaming response
full_response = ""
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
if chunk:
# Ensure chunk is string type
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
full_response += chunk
# Parse streaming response
references, response_chunks, errors = parse_streaming_response(
full_response
)
if errors:
print(f"❌ Errors in streaming response: {errors}")
return False
if references is None:
print("❌ No references found in streaming response")
return False
if not validate_references_format(references):
return False
if not response_chunks:
print("❌ No response chunks found in streaming response")
return False
print(f"✅ Streaming with references: Found {len(references)} references")
print(f" Response chunks: {len(response_chunks)}")
print(
f" Total response length: {sum(len(chunk) for chunk in response_chunks)} characters"
)
# Display reference list
if references:
print(" 📚 Reference List:")
for i, ref in enumerate(references, 1):
ref_id = ref.get("reference_id", "Unknown")
file_path = ref.get("file_path", "Unknown")
print(f" {i}. ID: {ref_id} | File: {file_path}")
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 1 failed: {str(e)}")
return False
# Test 2: Streaming with references disabled
print("\n🧪 Test 2: Streaming with references disabled")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": False},
headers=AUTH_HEADERS,
timeout=30,
stream=True,
)
if response.status_code == 200:
# Collect streaming response
full_response = ""
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
if chunk:
# Ensure chunk is string type
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
full_response += chunk
# Parse streaming response
references, response_chunks, errors = parse_streaming_response(
full_response
)
if errors:
print(f"❌ Errors in streaming response: {errors}")
return False
if references is not None:
print("❌ References should be None when include_references=False")
return False
if not response_chunks:
print("❌ No response chunks found in streaming response")
return False
print("✅ Streaming without references: No references present")
print(f" Response chunks: {len(response_chunks)}")
print(
f" Total response length: {sum(len(chunk) for chunk in response_chunks)} characters"
)
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 2 failed: {str(e)}")
return False
print("\n✅ /query/stream endpoint references tests passed!")
return True
@pytest.mark.integration
@pytest.mark.requires_api
def test_references_consistency():
"""Test references consistency across all endpoints"""
print("\n" + "=" * 60)
print("Testing references consistency across endpoints")
print("=" * 60)
query_text = "who authored LightRAG"
query_params = {
"query": query_text,
"mode": "mix",
"top_k": 10,
"chunk_top_k": 8,
"include_references": True,
}
references_data = {}
# Test /query endpoint
print("\n🧪 Testing /query endpoint")
print("-" * 40)
try:
response = requests.post(
f"{BASE_URL}/query", json=query_params, headers=AUTH_HEADERS, timeout=30
)
if response.status_code == 200:
data = response.json()
references_data["query"] = data.get("references", [])
print(f"✅ /query: {len(references_data['query'])} references")
else:
print(f"❌ /query failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ /query test failed: {str(e)}")
return False
# Test /query/stream endpoint
print("\n🧪 Testing /query/stream endpoint")
print("-" * 40)
try:
response = requests.post(
f"{BASE_URL}/query/stream",
json=query_params,
headers=AUTH_HEADERS,
timeout=30,
stream=True,
)
if response.status_code == 200:
full_response = ""
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
if chunk:
# Ensure chunk is string type
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
full_response += chunk
references, _, errors = parse_streaming_response(full_response)
if errors:
print(f"❌ Errors: {errors}")
return False
references_data["stream"] = references or []
print(f"✅ /query/stream: {len(references_data['stream'])} references")
else:
print(f"❌ /query/stream failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ /query/stream test failed: {str(e)}")
return False
# Test /query/data endpoint
print("\n🧪 Testing /query/data endpoint")
print("-" * 40)
try:
response = requests.post(
f"{BASE_URL}/query/data",
json=query_params,
headers=AUTH_HEADERS,
timeout=30,
)
if response.status_code == 200:
data = response.json()
query_data = data.get("data", {})
references_data["data"] = query_data.get("references", [])
print(f"✅ /query/data: {len(references_data['data'])} references")
else:
print(f"❌ /query/data failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ /query/data test failed: {str(e)}")
return False
# Compare references consistency
print("\n🔍 Comparing references consistency")
print("-" * 40)
# Convert to sets of (reference_id, file_path) tuples for comparison
def refs_to_set(refs):
return set(
(ref.get("reference_id", ""), ref.get("file_path", "")) for ref in refs
)
query_refs = refs_to_set(references_data["query"])
stream_refs = refs_to_set(references_data["stream"])
data_refs = refs_to_set(references_data["data"])
# Check consistency
consistency_passed = True
if query_refs != stream_refs:
print("❌ References mismatch between /query and /query/stream")
print(f" /query only: {query_refs - stream_refs}")
print(f" /query/stream only: {stream_refs - query_refs}")
consistency_passed = False
if query_refs != data_refs:
print("❌ References mismatch between /query and /query/data")
print(f" /query only: {query_refs - data_refs}")
print(f" /query/data only: {data_refs - query_refs}")
consistency_passed = False
if stream_refs != data_refs:
print("❌ References mismatch between /query/stream and /query/data")
print(f" /query/stream only: {stream_refs - data_refs}")
print(f" /query/data only: {data_refs - stream_refs}")
consistency_passed = False
if consistency_passed:
print("✅ All endpoints return consistent references")
print(f" Common references count: {len(query_refs)}")
# Display common reference list
if query_refs:
print(" 📚 Common Reference List:")
for i, (ref_id, file_path) in enumerate(sorted(query_refs), 1):
print(f" {i}. ID: {ref_id} | File: {file_path}")
return consistency_passed
@pytest.mark.integration
@pytest.mark.requires_api
def test_aquery_data_endpoint():
"""Test the /query/data endpoint"""
# Use unified configuration
endpoint = f"{BASE_URL}/query/data"
# Query request
query_request = {
"query": "who authored LighRAG",
"mode": "mix", # Use mixed mode to get the most comprehensive results
"top_k": 20,
"chunk_top_k": 15,
"max_entity_tokens": 4000,
"max_relation_tokens": 4000,
"max_total_tokens": 16000,
"enable_rerank": True,
}
print("=" * 60)
print("LightRAG aquery_data endpoint test")
print(
" Returns structured data including entities, relationships and text chunks"
)
print(" Can be used for custom processing and analysis")
print("=" * 60)
print(f"Query content: {query_request['query']}")
print(f"Query mode: {query_request['mode']}")
print(f"API endpoint: {endpoint}")
print("-" * 60)
try:
# Send request
print("Sending request...")
start_time = time.time()
response = requests.post(
endpoint, json=query_request, headers=AUTH_HEADERS, timeout=30
)
end_time = time.time()
response_time = end_time - start_time
print(f"Response time: {response_time:.2f} seconds")
print(f"HTTP status code: {response.status_code}")
if response.status_code == 200:
data = response.json()
print_query_results(data)
else:
print(f"Request failed: {response.status_code}")
print(f"Error message: {response.text}")
except requests.exceptions.ConnectionError:
print("❌ Connection failed: Please ensure LightRAG API service is running")
print(" Start command: python -m lightrag.api.lightrag_server")
except requests.exceptions.Timeout:
print("❌ Request timeout: Query processing took too long")
except Exception as e:
print(f"❌ Error occurred: {str(e)}")
def print_query_results(data: Dict[str, Any]):
"""Format and print query results"""
# Check for new data format with status and message
status = data.get("status", "unknown")
message = data.get("message", "")
print(f"\n📋 Query Status: {status}")
if message:
print(f"📋 Message: {message}")
# Handle new nested data format
query_data = data.get("data", {})
# Fallback to old format if new format is not present
if not query_data and any(
key in data for key in ["entities", "relationships", "chunks"]
):
print(" (Using legacy data format)")
query_data = data
entities = query_data.get("entities", [])
relationships = query_data.get("relationships", [])
chunks = query_data.get("chunks", [])
references = query_data.get("references", [])
print("\n📊 Query result statistics:")
print(f" Entity count: {len(entities)}")
print(f" Relationship count: {len(relationships)}")
print(f" Text chunk count: {len(chunks)}")
print(f" Reference count: {len(references)}")
# Print metadata (now at top level in new format)
metadata = data.get("metadata", {})
if metadata:
print("\n🔍 Query metadata:")
print(f" Query mode: {metadata.get('query_mode', 'unknown')}")
keywords = metadata.get("keywords", {})
if keywords:
high_level = keywords.get("high_level", [])
low_level = keywords.get("low_level", [])
if high_level:
print(f" High-level keywords: {', '.join(high_level)}")
if low_level:
print(f" Low-level keywords: {', '.join(low_level)}")
processing_info = metadata.get("processing_info", {})
if processing_info:
print(" Processing info:")
for key, value in processing_info.items():
print(f" {key}: {value}")
# Print entity information
if entities:
print("\n👥 Retrieved entities (first 5):")
for i, entity in enumerate(entities[:5]):
entity_name = entity.get("entity_name", "Unknown")
entity_type = entity.get("entity_type", "Unknown")
description = entity.get("description", "No description")
file_path = entity.get("file_path", "Unknown source")
reference_id = entity.get("reference_id", "No reference")
print(f" {i + 1}. {entity_name} ({entity_type})")
print(
f" Description: {description[:100]}{'...' if len(description) > 100 else ''}"
)
print(f" Source: {file_path}")
print(f" Reference ID: {reference_id}")
print()
# Print relationship information
if relationships:
print("🔗 Retrieved relationships (first 5):")
for i, rel in enumerate(relationships[:5]):
src = rel.get("src_id", "Unknown")
tgt = rel.get("tgt_id", "Unknown")
description = rel.get("description", "No description")
keywords = rel.get("keywords", "No keywords")
file_path = rel.get("file_path", "Unknown source")
reference_id = rel.get("reference_id", "No reference")
print(f" {i + 1}. {src}{tgt}")
print(f" Keywords: {keywords}")
print(
f" Description: {description[:100]}{'...' if len(description) > 100 else ''}"
)
print(f" Source: {file_path}")
print(f" Reference ID: {reference_id}")
print()
# Print text chunk information
if chunks:
print("📄 Retrieved text chunks (first 3):")
for i, chunk in enumerate(chunks[:3]):
content = chunk.get("content", "No content")
file_path = chunk.get("file_path", "Unknown source")
chunk_id = chunk.get("chunk_id", "Unknown ID")
reference_id = chunk.get("reference_id", "No reference")
print(f" {i + 1}. Text chunk ID: {chunk_id}")
print(f" Source: {file_path}")
print(f" Reference ID: {reference_id}")
print(
f" Content: {content[:200]}{'...' if len(content) > 200 else ''}"
)
print()
# Print references information (new in updated format)
if references:
print("📚 References:")
for i, ref in enumerate(references):
reference_id = ref.get("reference_id", "Unknown ID")
file_path = ref.get("file_path", "Unknown source")
print(f" {i + 1}. Reference ID: {reference_id}")
print(f" File Path: {file_path}")
print()
print("=" * 60)
@pytest.mark.integration
@pytest.mark.requires_api
def compare_with_regular_query():
"""Compare results between regular query and data query"""
query_text = "LightRAG的作者是谁"
print("\n🔄 Comparison test: Regular query vs Data query")
print("-" * 60)
# Regular query
try:
print("1. Regular query (/query):")
regular_response = requests.post(
f"{BASE_URL}/query",
json={"query": query_text, "mode": "mix"},
headers=AUTH_HEADERS,
timeout=30,
)
if regular_response.status_code == 200:
regular_data = regular_response.json()
response_text = regular_data.get("response", "No response")
print(
f" Generated answer: {response_text[:300]}{'...' if len(response_text) > 300 else ''}"
)
else:
print(f" Regular query failed: {regular_response.status_code}")
if regular_response.status_code == 403:
print(" Authentication failed - Please check API Key configuration")
elif regular_response.status_code == 401:
print(" Unauthorized - Please check authentication information")
print(f" Error details: {regular_response.text}")
except Exception as e:
print(f" Regular query error: {str(e)}")
@pytest.mark.integration
@pytest.mark.requires_api
def run_all_reference_tests():
"""Run all reference-related tests"""
print("\n" + "🚀" * 20)
print("LightRAG References Test Suite")
print("🚀" * 20)
all_tests_passed = True
# Test 1: /query endpoint references
try:
if not test_query_endpoint_references():
all_tests_passed = False
except Exception as e:
print(f"❌ /query endpoint test failed with exception: {str(e)}")
all_tests_passed = False
# Test 2: /query/stream endpoint references
try:
if not test_query_stream_endpoint_references():
all_tests_passed = False
except Exception as e:
print(f"❌ /query/stream endpoint test failed with exception: {str(e)}")
all_tests_passed = False
# Test 3: References consistency across endpoints
try:
if not test_references_consistency():
all_tests_passed = False
except Exception as e:
print(f"❌ References consistency test failed with exception: {str(e)}")
all_tests_passed = False
# Final summary
print("\n" + "=" * 60)
print("TEST SUITE SUMMARY")
print("=" * 60)
if all_tests_passed:
print("🎉 ALL TESTS PASSED!")
print("✅ /query endpoint references functionality works correctly")
print("✅ /query/stream endpoint references functionality works correctly")
print("✅ References are consistent across all endpoints")
print("\n🔧 System is ready for production use with reference support!")
else:
print("❌ SOME TESTS FAILED!")
print("Please check the error messages above and fix the issues.")
print("\n🔧 System needs attention before production deployment.")
return all_tests_passed
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "--references-only":
# Run only the new reference tests
success = run_all_reference_tests()
sys.exit(0 if success else 1)
else:
# Run original tests plus new reference tests
print("Running original aquery_data endpoint test...")
test_aquery_data_endpoint()
print("\nRunning comparison test...")
compare_with_regular_query()
print("\nRunning new reference tests...")
run_all_reference_tests()
print("\n💡 Usage tips:")
print("1. Ensure LightRAG API service is running")
print("2. Adjust base_url and authentication information as needed")
print("3. Modify query parameters to test different retrieval strategies")
print("4. Data query results can be used for further analysis and processing")
print("5. Run with --references-only flag to test only reference functionality")
+271
View File
@@ -0,0 +1,271 @@
#!/bin/bash
# LightRAG aquery_data endpoint test script
# Use curl command to test the new /query/data endpoint and validate the new data format
echo "🚀 LightRAG aquery_data Endpoint Test (New Data Format Validation)"
echo "=================================================="
# Base URL (adjust according to actual deployment)
BASE_URL="http://localhost:9621"
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test result statistics
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Function to validate success response format
validate_success_response() {
local response="$1"
local test_name="$2"
local expected_mode="$3"
echo -e "${BLUE}Validating $test_name response format...${NC}"
# Check if valid JSON
if ! echo "$response" | jq . >/dev/null 2>&1; then
echo -e "${RED}❌ Response is not valid JSON format${NC}"
return 1
fi
# Validate required fields
local status=$(echo "$response" | jq -r '.status // "missing"')
local message=$(echo "$response" | jq -r '.message // "missing"')
local data_exists=$(echo "$response" | jq 'has("data")')
local metadata_exists=$(echo "$response" | jq 'has("metadata")')
echo " Status: $status"
echo " Message: $message"
# Validate data structure
if [[ "$data_exists" == "true" ]]; then
local entities_count=$(echo "$response" | jq '.data.entities | length // 0')
local relationships_count=$(echo "$response" | jq '.data.relationships | length // 0')
local chunks_count=$(echo "$response" | jq '.data.chunks | length // 0')
local references_count=$(echo "$response" | jq '.data.references | length // 0')
echo " Data.entities: $entities_count"
echo " Data.relationships: $relationships_count"
echo " Data.chunks: $chunks_count"
echo " Data.references: $references_count"
else
echo -e "${RED} ❌ Missing 'data' field${NC}"
return 1
fi
# Validate metadata
if [[ "$metadata_exists" == "true" ]]; then
local query_mode=$(echo "$response" | jq -r '.metadata.query_mode // "missing"')
local keywords_exists=$(echo "$response" | jq 'has("metadata") and (.metadata | has("keywords"))')
local processing_info_exists=$(echo "$response" | jq 'has("metadata") and (.metadata | has("processing_info"))')
echo " Metadata.query_mode: $query_mode"
echo " Metadata.keywords: $keywords_exists"
echo " Metadata.processing_info: $processing_info_exists"
# Validate if query mode matches
if [[ "$expected_mode" != "" && "$query_mode" != "$expected_mode" ]]; then
echo -e "${YELLOW} ⚠️ Query mode mismatch: expected '$expected_mode', actual '$query_mode'${NC}"
fi
else
echo -e "${RED} ❌ Missing 'metadata' field${NC}"
return 1
fi
# Validate status
if [[ "$status" == "success" ]]; then
echo -e "${GREEN} ✅ Response format validation passed${NC}"
return 0
else
echo -e "${RED} ❌ Status is not 'success': $status${NC}"
return 1
fi
}
# Function to validate error response format
validate_error_response() {
local response="$1"
local test_name="$2"
echo -e "${BLUE}Validating $test_name response format...${NC}"
# Check if valid JSON
if ! echo "$response" | jq . >/dev/null 2>&1; then
echo -e "${RED}❌ Response is not valid JSON format${NC}"
return 1
fi
# Validate required fields
local status=$(echo "$response" | jq -r '.status // "missing"')
local message=$(echo "$response" | jq -r '.message // "missing"')
local data_exists=$(echo "$response" | jq 'has("data")')
local metadata_exists=$(echo "$response" | jq 'has("metadata")')
echo " Status: $status"
echo " Message: $message"
# Validate basic structure exists
if [[ "$data_exists" != "true" ]]; then
echo -e "${RED} ❌ Missing 'data' field${NC}"
return 1
fi
if [[ "$metadata_exists" != "true" ]]; then
echo -e "${RED} ❌ Missing 'metadata' field${NC}"
return 1
fi
echo " Data: {}"
echo " Metadata: {}"
# Validate status should be failure
if [[ "$status" == "failure" ]]; then
echo -e "${GREEN} ✅ Error response format validation passed${NC}"
return 0
else
echo -e "${RED} ❌ Status is not 'failure': $status${NC}"
return 1
fi
}
# Function to run success test
run_success_test() {
local test_name="$1"
local query_data="$2"
local expected_mode="$3"
local print_json="${4:-false}" # Optional parameter: whether to print JSON response (default: false)
echo ""
echo "=================================="
echo -e "${BLUE}$test_name${NC}"
echo "=================================="
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Send request
echo "Sending request..."
local response=$(curl -s -X POST "${BASE_URL}/query/data" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secure-api-key-here-123" \
-d "$query_data")
# Check if curl succeeded
if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Request failed - cannot connect to server${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
# Print JSON response if requested
if [[ "$print_json" == "true" ]]; then
echo ""
echo "Response JSON:"
echo "$response" | jq '.' 2>/dev/null || echo "$response"
echo ""
fi
# Validate response
if validate_success_response "$response" "$test_name" "$expected_mode"; then
PASSED_TESTS=$((PASSED_TESTS + 1))
echo -e "${GREEN}$test_name test passed${NC}"
else
FAILED_TESTS=$((FAILED_TESTS + 1))
echo -e "${RED}$test_name test failed${NC}"
echo "Raw response:"
echo "$response" | jq '.' 2>/dev/null || echo "$response"
fi
}
# Function to run error test
run_error_test() {
local test_name="$1"
local query_data="$2"
echo ""
echo "=================================="
echo -e "${BLUE}$test_name${NC}"
echo "=================================="
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Send request
echo "Sending request..."
local response=$(curl -s -X POST "${BASE_URL}/query/data" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secure-api-key-here-123" \
-d "$query_data")
# Check if curl succeeded
if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Request failed - cannot connect to server${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
# Validate response
if validate_error_response "$response" "$test_name"; then
PASSED_TESTS=$((PASSED_TESTS + 1))
echo -e "${GREEN}$test_name test passed${NC}"
else
FAILED_TESTS=$((FAILED_TESTS + 1))
echo -e "${RED}$test_name test failed${NC}"
echo "Raw response:"
echo "$response" | jq '.' 2>/dev/null || echo "$response"
fi
}
# Start tests
echo "Starting tests for new /query/data endpoint data format..."
echo ""
# Test 1: Basic query test (mix mode)
run_success_test "1. Basic Query Test (mix mode)" '{
"query": "What is GraphRAG",
"mode": "mix",
"top_k": 5
}' "mix" "true" # Output full JSON
# Test 2: Detailed parameter query test (hybrid mode)
run_success_test "2. Detailed Parameter Query Test (hybrid mode)" '{
"query": "What is GraphRAG",
"mode": "hybrid",
"top_k": 5,
"chunk_top_k": 8,
"max_entity_tokens": 4000,
"max_relation_tokens": 4000,
"max_total_tokens": 16000,
"enable_rerank": true,
"response_type": "Multiple Paragraphs"
}' "hybrid"
# Output test result statistics
echo ""
echo "=================================================="
echo -e "${BLUE}Test Result Statistics${NC}"
echo "=================================================="
echo -e "Total tests: ${BLUE}$TOTAL_TESTS${NC}"
echo -e "Passed tests: ${GREEN}$PASSED_TESTS${NC}"
echo -e "Failed tests: ${RED}$FAILED_TESTS${NC}"
if [[ $FAILED_TESTS -eq 0 ]]; then
echo -e "${GREEN}🎉 All tests passed! New data format adaptation successful!${NC}"
exit 0
else
echo -e "${RED}⚠️ $FAILED_TESTS test(s) failed, please check the issues${NC}"
exit 1
fi
echo ""
echo "💡 Usage Instructions:"
echo "1. Ensure LightRAG API service is running (python -m lightrag.api.lightrag_server)"
echo "2. Adjust BASE_URL as needed"
echo "3. If authentication is required, add -H \"Authorization: Bearer your-token\""
echo "4. Install jq for better JSON formatting output: brew install jq (macOS) or apt install jq (Ubuntu)"
echo "5. Script will automatically validate new data format structure: status, message, data, metadata"
@@ -0,0 +1,403 @@
import pytest
from lightrag.constants import SOURCE_IDS_LIMIT_METHOD_KEEP
from lightrag.constants import GRAPH_FIELD_SEP
from lightrag.operate import (
_handle_single_entity_extraction,
_merge_nodes_then_upsert,
_normalize_text_extraction_record_attributes,
_handle_single_relationship_extraction,
)
from lightrag import utils_graph
from lightrag.utils import VectorStorageConsistencyError
class DummyGraphStorage:
def __init__(self, node=None):
self.node = node
self.upserted_nodes = []
async def get_node(self, node_id):
return self.node
async def upsert_node(self, node_id, node_data):
self.upserted_nodes.append((node_id, node_data))
self.node = dict(node_data)
class DummyVectorStorage:
def __init__(self):
self.global_config = {"workspace": "test"}
self.upserts = []
self.deletes = []
async def upsert(self, data):
self.upserts.append(data)
return None
async def delete(self, ids):
self.deletes.append(ids)
return None
async def get_by_id(self, id_):
return None
async def index_done_callback(self):
return True
class DummyAsyncContext:
async def __aenter__(self):
return None
async def __aexit__(self, exc_type, exc, tb):
return False
class DummyMergeGraphStorage:
def __init__(self):
self.nodes = {
"Canonical": {
"entity_id": "Canonical",
"description": "canonical desc",
"entity_type": "ORG",
"source_id": "chunk-1",
"file_path": "canonical.md",
},
"Alias": {
"entity_id": "Alias",
"description": "alias desc",
"entity_type": "ORG",
"source_id": "chunk-2",
"file_path": "alias.md",
},
"Neighbor": {
"entity_id": "Neighbor",
"description": "neighbor desc",
"entity_type": "ORG",
"source_id": "chunk-3",
"file_path": "neighbor.md",
},
}
self.edges = {
("Alias", "Neighbor"): {
"description": "rel desc",
"keywords": "alias",
"source_id": "chunk-rel",
"weight": 1.0,
"file_path": "rel.md",
}
}
async def has_node(self, node_id):
return node_id in self.nodes
async def get_node(self, node_id):
return self.nodes[node_id]
async def upsert_node(self, node_id, node_data):
self.nodes[node_id] = dict(node_data)
async def get_node_edges(self, node_id):
results = []
for src, tgt in self.edges:
if src == node_id or tgt == node_id:
results.append((src, tgt))
return results
async def get_edge(self, src, tgt):
return self.edges.get((src, tgt)) or self.edges.get((tgt, src))
async def upsert_edge(self, src, tgt, edge_data):
self.edges[(src, tgt)] = dict(edge_data)
async def delete_node(self, node_id):
self.nodes.pop(node_id, None)
self.edges = {
(src, tgt): data
for (src, tgt), data in self.edges.items()
if src != node_id and tgt != node_id
}
async def index_done_callback(self):
return True
@pytest.mark.asyncio
async def test_merge_nodes_then_upsert_handles_missing_legacy_description():
graph = DummyGraphStorage(node={"source_id": "chunk-1"})
global_config = {
"source_ids_limit_method": SOURCE_IDS_LIMIT_METHOD_KEEP,
"max_source_ids_per_entity": 20,
}
result = await _merge_nodes_then_upsert(
entity_name="LegacyEntity",
nodes_data=[],
knowledge_graph_inst=graph,
entity_vdb=None,
global_config=global_config,
)
assert result["description"] == "Entity LegacyEntity"
assert graph.upserted_nodes[-1][1]["description"] == "Entity LegacyEntity"
@pytest.mark.asyncio
async def test_acreate_entity_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.acreate_entity(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
entity_name="EntityA",
entity_data={"description": " "},
)
@pytest.mark.asyncio
async def test_acreate_relation_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.acreate_relation(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
source_entity="A",
target_entity="B",
relation_data={"description": ""},
)
@pytest.mark.asyncio
async def test_aedit_entity_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.aedit_entity(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
entity_name="EntityA",
updated_data={"description": None},
)
@pytest.mark.asyncio
async def test_aedit_relation_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.aedit_relation(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
source_entity="A",
target_entity="B",
updated_data={"description": " "},
)
@pytest.mark.asyncio
async def test_aedit_entity_allows_updates_without_description(monkeypatch):
async def fake_edit_impl(*args, **kwargs):
return {"entity_name": "EntityA", "description": "kept", "source_id": "chunk-1"}
monkeypatch.setattr(
utils_graph, "get_storage_keyed_lock", lambda *a, **k: DummyAsyncContext()
)
monkeypatch.setattr(utils_graph, "_edit_entity_impl", fake_edit_impl)
result = await utils_graph.aedit_entity(
chunk_entity_relation_graph=None,
entities_vdb=DummyVectorStorage(),
relationships_vdb=DummyVectorStorage(),
entity_name="EntityA",
updated_data={"entity_type": "ORG"},
)
assert result["operation_summary"]["operation_status"] == "success"
def test_handle_single_relationship_extraction_ignores_empty_description():
relation = _handle_single_relationship_extraction(
["relation", "Alice", "Bob", "works_with", " "],
chunk_key="chunk-1",
timestamp=1,
)
assert relation is None
def test_mis_prefixed_relation_row_is_recovered():
record = _normalize_text_extraction_record_attributes(
["entity", "Alice", "Acme Corp", "founded", "Alice founded Acme Corp."],
chunk_key="chunk-1",
)
relation = _handle_single_relationship_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert relation is not None
assert relation["src_id"] == "Alice"
assert relation["tgt_id"] == "Acme Corp"
def test_four_part_entity_row_remains_entity():
record = _normalize_text_extraction_record_attributes(
["entity", "Alice", "Person", "Alice is the founder of Acme Corp."],
chunk_key="chunk-1",
)
entity = _handle_single_entity_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert entity is not None
assert entity["entity_name"] == "Alice"
def test_malformed_recovered_relation_still_fails():
record = _normalize_text_extraction_record_attributes(
["entity", "Alice", "Acme Corp", "founded", " "],
chunk_key="chunk-1",
)
relation = _handle_single_relationship_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert relation is None
def test_unrelated_five_part_prefix_remains_invalid():
record = _normalize_text_extraction_record_attributes(
["edge", "Alice", "Acme Corp", "founded", "Alice founded Acme Corp."],
chunk_key="chunk-1",
)
relation = _handle_single_relationship_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert relation is None
@pytest.mark.asyncio
async def test_merge_entities_preserves_file_path_in_vector_updates(monkeypatch):
graph = DummyMergeGraphStorage()
entities_vdb = DummyVectorStorage()
relationships_vdb = DummyVectorStorage()
async def fake_get_entity_info(*args, **kwargs):
return {"entity_name": "Canonical"}
monkeypatch.setattr(utils_graph, "get_entity_info", fake_get_entity_info)
await utils_graph._merge_entities_impl(
chunk_entity_relation_graph=graph,
entities_vdb=entities_vdb,
relationships_vdb=relationships_vdb,
source_entities=["Alias", "Canonical"],
target_entity="Canonical",
)
relationship_payload = relationships_vdb.upserts[-1]
entity_payload = entities_vdb.upserts[-1]
assert next(iter(relationship_payload.values()))["file_path"] == "rel.md"
assert set(
next(iter(entity_payload.values()))["file_path"].split(GRAPH_FIELD_SEP)
) == {
"alias.md",
"canonical.md",
}
@pytest.mark.asyncio
async def test_merge_response_is_built_from_graph_without_reading_vdb():
# The merge response is built from graph data only. Reading the vector
# store is redundant (the graph is authoritative) and previously leaked a
# non-JSON-serializable embedding (pgvector -> numpy) into the API
# response, causing a 500 on /graph/entity/edit with all-PostgreSQL.
graph = DummyMergeGraphStorage()
entities_vdb = DummyVectorStorage()
relationships_vdb = DummyVectorStorage()
vdb_reads = []
inner_get_by_id = entities_vdb.get_by_id
async def _tracked_get_by_id(id_):
vdb_reads.append(id_)
return await inner_get_by_id(id_)
entities_vdb.get_by_id = _tracked_get_by_id
result = await utils_graph._merge_entities_impl(
chunk_entity_relation_graph=graph,
entities_vdb=entities_vdb,
relationships_vdb=relationships_vdb,
source_entities=["Alias", "Canonical"],
target_entity="Canonical",
)
assert "graph_data" in result
assert "vector_data" not in result
# The response did not read the entity vector store.
assert vdb_reads == []
@pytest.mark.asyncio
async def test_aedit_entity_merge_propagates_consistency_error(monkeypatch):
"""rename-with-merge must surface VectorStorageConsistencyError, not swallow it.
The graph is updated during the merge, then the (deferred) vector-store
flush fails. aedit_entity used to fold every merge exception into a
partial-success summary and return normally, so the /graph/entity/edit
route reported HTTP 200 "success" and hid the consistency failure that the
fail-loud merge path exists to surface. It must re-raise instead.
"""
graph = DummyMergeGraphStorage()
entities_vdb = DummyVectorStorage()
relationships_vdb = DummyVectorStorage()
# Deferred-embedding flush failure: upsert buffers, index_done_callback raises.
async def _boom():
raise RuntimeError("embedder down")
relationships_vdb.index_done_callback = _boom
# Single-attempt VDB wrapper (no retry delays) + a no-op storage lock.
async def _single_attempt(operation, **kwargs):
await operation()
monkeypatch.setattr(
utils_graph, "safe_vdb_operation_with_exception", _single_attempt
)
monkeypatch.setattr(
utils_graph, "get_storage_keyed_lock", lambda *a, **k: DummyAsyncContext()
)
async def fake_get_entity_info(*args, **kwargs):
return {"entity_name": "Canonical"}
monkeypatch.setattr(utils_graph, "get_entity_info", fake_get_entity_info)
with pytest.raises(VectorStorageConsistencyError) as excinfo:
await utils_graph.aedit_entity(
chunk_entity_relation_graph=graph,
entities_vdb=entities_vdb,
relationships_vdb=relationships_vdb,
entity_name="Alias",
updated_data={"entity_name": "Canonical"},
allow_rename=True,
allow_merge=True,
)
assert "lightrag-rebuild-vdb" in str(excinfo.value)
# Fail-loud happened before source deletion: 'Alias' is still in the graph.
assert "Alias" in graph.nodes
@@ -0,0 +1,694 @@
"""Tests for the `/documents/text(s)` ``chunking`` request object.
Three concerns:
1. **Synchronous validation**: malformed ``chunking`` is rejected at
request-parse time (HTTP 422 / ``ValidationError``) — never deferred to
the background indexing task, where the HTTP response is already sent.
The per-strategy typed params models do full type + value checking, not
just unknown-key detection.
2. **``_resolve_text_chunking``**: a validated ``chunking`` config is frozen
into ``(process_options, chunk_options)``; ``chunk_token_size`` and the
strategy params land in the selected strategy's sub-dict, overriding any
env-derived value, while the other strategy sub-dicts are dropped (slim).
3. **Route forwarding**: ``/documents/text`` and ``/documents/texts`` forward
``request.chunking`` to ``pipeline_index_texts`` and return 422 (without
scheduling any background work) for a malformed body.
"""
import importlib
import sys
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_dr = importlib.import_module("lightrag.api.routers.document_routes")
sys.argv = _original_argv
TextChunkingConfig = _dr.TextChunkingConfig
InsertTextRequest = _dr.InsertTextRequest
_resolve_text_chunking = _dr._resolve_text_chunking
create_document_routes = _dr.create_document_routes
from lightrag.constants import ( # noqa: E402
PROCESS_OPTION_CHUNK_FIXED,
PROCESS_OPTION_CHUNK_PARAGRAH,
PROCESS_OPTION_CHUNK_RECURSIVE,
PROCESS_OPTION_CHUNK_VECTOR,
)
from lightrag.parser.routing import default_chunker_config # noqa: E402
pytestmark = pytest.mark.offline
_ALL_STRATEGY_KEYS = {
"fixed_token",
"recursive_character",
"semantic_vector",
"paragraph_semantic",
}
# ---------------------------------------------------------------------------
# 1. Synchronous validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"body",
[
# wrong types (strict rejects lax coercion)
{"strategy": "fixed_token", "params": {"chunk_token_size": True}},
{"strategy": "fixed_token", "params": {"chunk_token_size": "5"}},
{"strategy": "fixed_token", "params": {"chunk_token_size": 1.5}},
{"strategy": "fixed_token", "params": {"chunk_overlap_token_size": "bad"}},
{"strategy": "fixed_token", "params": {"split_by_character": 123}},
{"strategy": "fixed_token", "params": {"split_by_character_only": 1}},
{"strategy": "recursive_character", "params": {"separators": "abc"}},
{"strategy": "recursive_character", "params": {"separators": [1, 2]}},
# value / range
{"strategy": "fixed_token", "params": {"chunk_token_size": 0}},
{"strategy": "recursive_character", "params": {"chunk_overlap_token_size": -1}},
{"strategy": "semantic_vector", "params": {"buffer_size": 0}},
{"strategy": "semantic_vector", "params": {"buffer_size": True}},
{
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_type": "p99"},
},
{
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": 0},
},
{
# strict float rejects strings (no lax numeric-string coercion)
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": "95"},
},
{
# strict float rejects bool (bool is an int subclass, undesirable here)
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": True},
},
{
# > 100 with an explicit percentile/gradient type is rejected at
# parse time (both fields present, no inheritance ambiguity).
"strategy": "semantic_vector",
"params": {
"breakpoint_threshold_type": "percentile",
"breakpoint_threshold_amount": 150,
},
},
{
# malformed regex must be compiled/rejected at parse time
"strategy": "semantic_vector",
"params": {"sentence_split_regex": "("},
},
# cross-field
{
"strategy": "fixed_token",
"params": {"chunk_token_size": 100, "chunk_overlap_token_size": 200},
},
# unknown / wrong-for-strategy keys
{"strategy": "fixed_token", "params": {"bogus": 1}},
{"strategy": "fixed_token", "params": {"separators": ["x"]}},
{"strategy": "recursive_character", "params": {"buffer_size": 1}},
],
)
def test_chunking_config_rejects_malformed(body):
with pytest.raises(ValidationError):
TextChunkingConfig.model_validate(body)
def test_chunking_config_defaults_to_fixed_token():
cfg = TextChunkingConfig.model_validate({"params": {"chunk_token_size": 500}})
assert cfg.strategy == "fixed_token"
assert cfg.params == {"chunk_token_size": 500}
def test_chunking_config_normalizes_to_supplied_keys_only():
# int amount is coerced to float; only the supplied key survives.
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 95}}
)
assert cfg.params == {"breakpoint_threshold_amount": 95.0}
def test_chunking_config_amount_in_range_for_std_deviation():
# standard_deviation only requires > 0 (no [0, 100] ceiling).
cfg = TextChunkingConfig.model_validate(
{
"strategy": "semantic_vector",
"params": {
"breakpoint_threshold_type": "standard_deviation",
"breakpoint_threshold_amount": 3.5,
},
}
)
assert cfg.params["breakpoint_threshold_amount"] == 3.5
def test_chunking_config_amount_over_100_without_type_is_deferred():
# Type omitted -> the (0, 100] ceiling cannot be decided at parse time
# (the effective type may be inherited), so the model must NOT assume
# percentile and reject. _resolve_text_chunking applies the ceiling later.
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 150}}
)
assert cfg.params == {"breakpoint_threshold_amount": 150.0}
def test_chunking_config_accepts_int_amount_widened_to_float():
# Strict float accepts an int (JSON 95) and widens it to 95.0 — the common
# documented threshold magnitude. (str/bool are rejected; see the
# rejection matrix above.) Exercised via both python and JSON validation
# modes so the FastAPI request path (which parses JSON) stays covered.
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 95}}
)
assert cfg.params == {"breakpoint_threshold_amount": 95.0}
assert isinstance(cfg.params["breakpoint_threshold_amount"], float)
cfg_json = TextChunkingConfig.model_validate_json(
'{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 95}}'
)
assert cfg_json.params == {"breakpoint_threshold_amount": 95.0}
def test_chunking_config_accepts_valid_sentence_split_regex():
cfg = TextChunkingConfig.model_validate(
{
"strategy": "semantic_vector",
"params": {"sentence_split_regex": r"(?<=[.?!])\s+"},
}
)
assert cfg.params == {"sentence_split_regex": r"(?<=[.?!])\s+"}
def test_chunking_config_drops_explicit_null():
# Explicit null means "inherit the default" (every param field is
# Optional/None=inherit), so it must be dropped — not merged over the
# resolved default, which would later make the chunker do int(None).
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": None}}
)
assert cfg.params == {}
def test_chunking_config_keeps_real_value_drops_sibling_null():
cfg = TextChunkingConfig.model_validate(
{
"strategy": "fixed_token",
"params": {"chunk_token_size": 500, "split_by_character": None},
}
)
assert cfg.params == {"chunk_token_size": 500}
def test_insert_text_request_rejects_malformed_chunking():
with pytest.raises(ValidationError):
InsertTextRequest.model_validate(
{
"text": "hi",
"file_source": "a.md",
"chunking": {
"strategy": "recursive_character",
"params": {"separators": "notalist"},
},
}
)
# ---------------------------------------------------------------------------
# 2. _resolve_text_chunking
# ---------------------------------------------------------------------------
def _stub_rag(addon_params=None):
return SimpleNamespace(
addon_params=addon_params if addon_params is not None else {}
)
def test_resolve_none_keeps_default_fixed():
process_options, chunk_options = _resolve_text_chunking(None, _stub_rag())
assert process_options == PROCESS_OPTION_CHUNK_FIXED
assert "fixed_token" in chunk_options
@pytest.mark.parametrize(
"strategy,expected_po,key",
[
("fixed_token", PROCESS_OPTION_CHUNK_FIXED, "fixed_token"),
("recursive_character", PROCESS_OPTION_CHUNK_RECURSIVE, "recursive_character"),
("semantic_vector", PROCESS_OPTION_CHUNK_VECTOR, "semantic_vector"),
("paragraph_semantic", PROCESS_OPTION_CHUNK_PARAGRAH, "paragraph_semantic"),
],
)
def test_resolve_maps_strategy_and_writes_size_into_subdict(strategy, expected_po, key):
cfg = TextChunkingConfig.model_validate(
{"strategy": strategy, "params": {"chunk_token_size": 777}}
)
process_options, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert process_options == expected_po
# chunk_token_size lands in the strategy sub-dict for ALL strategies
# (F included, post-cleanup) — that's where process_single_document reads it.
assert chunk_options[key]["chunk_token_size"] == 777
# slim contract: other strategies' sub-dicts are dropped
for other in _ALL_STRATEGY_KEYS - {key}:
assert other not in chunk_options
def test_resolve_merges_strategy_params():
cfg = TextChunkingConfig.model_validate(
{
"strategy": "recursive_character",
"params": {"separators": ["A", "B"], "chunk_overlap_token_size": 0},
}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert chunk_options["recursive_character"]["separators"] == ["A", "B"]
assert chunk_options["recursive_character"]["chunk_overlap_token_size"] == 0
def test_resolve_size_overrides_env_for_recursive(monkeypatch):
monkeypatch.setenv("CHUNK_R_SIZE", "999")
addon = {"chunker": default_chunker_config()}
# sanity: env baked into the R sub-dict
assert addon["chunker"]["recursive_character"]["chunk_token_size"] == 999
cfg = TextChunkingConfig.model_validate(
{"strategy": "recursive_character", "params": {"chunk_token_size": 1234}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
# API value wins over the env-derived sub-dict value.
assert chunk_options["recursive_character"]["chunk_token_size"] == 1234
def test_resolve_split_by_character_only_false_overrides_env(monkeypatch):
# The API path can express an explicit False (a plain dict merge), unlike
# the ainsert positional-arg path. Prove it overrides an env-True default.
monkeypatch.setenv("CHUNK_F_SPLIT_BY_CHARACTER_ONLY", "true")
addon = {"chunker": default_chunker_config()}
assert addon["chunker"]["fixed_token"]["split_by_character_only"] is True
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"split_by_character_only": False}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["fixed_token"]["split_by_character_only"] is False
def test_resolve_drop_references_request_false_overrides_env_true(monkeypatch):
# The JSON text API can express an explicit ``drop_references=False`` that
# overrides ``CHUNK_P_DROP_REFERENCES=true`` (resolved via slim_chunk_options
# then overlaid by the request params). Mirrors the split_by_character_only
# override above for the paragraph-semantic switch.
monkeypatch.setenv("CHUNK_P_DROP_REFERENCES", "true")
cfg = TextChunkingConfig.model_validate(
{"strategy": "paragraph_semantic", "params": {"drop_references": False}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert chunk_options["paragraph_semantic"]["drop_references"] is False
def test_resolve_drop_references_env_true_without_request_param(monkeypatch):
# No request param → the env-true default survives into the snapshot.
monkeypatch.setenv("CHUNK_P_DROP_REFERENCES", "true")
cfg = TextChunkingConfig.model_validate({"strategy": "paragraph_semantic"})
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert chunk_options["paragraph_semantic"]["drop_references"] is True
def test_resolve_rejects_size_below_inherited_overlap(monkeypatch):
# Overlap is inherited from addon_params (not in the request), so the
# request model can't catch it — _resolve_text_chunking must.
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
addon = {"chunker": default_chunker_config()}
assert addon["chunker"]["fixed_token"]["chunk_overlap_token_size"] == 100
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": 50}}
)
with pytest.raises(ValueError, match="chunk_overlap_token_size"):
_resolve_text_chunking(cfg, _stub_rag(addon))
def test_resolve_allows_size_above_inherited_overlap(monkeypatch):
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": 400}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["fixed_token"]["chunk_token_size"] == 400
def test_resolve_skips_overlap_check_for_delimiter_only(monkeypatch):
# Delimiter-only fixed-token chunking never uses overlap, so a small
# chunk_token_size below the inherited overlap must NOT be rejected.
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{
"strategy": "fixed_token",
"params": {
"split_by_character": "\n\n",
"split_by_character_only": True,
"chunk_token_size": 50,
},
}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["fixed_token"]["chunk_token_size"] == 50
def test_resolve_enforces_overlap_when_only_flag_without_delimiter(monkeypatch):
# split_by_character_only is a no-op without split_by_character: the chunker
# falls back to normal token windowing, which DOES use overlap — so the
# overlap < size check must still fire here.
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
monkeypatch.delenv("CHUNK_F_SPLIT_BY_CHARACTER", raising=False)
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{
"strategy": "fixed_token",
"params": {"split_by_character_only": True, "chunk_token_size": 50},
}
)
with pytest.raises(ValueError, match="chunk_overlap_token_size"):
_resolve_text_chunking(cfg, _stub_rag(addon))
def test_resolve_allows_amount_over_100_with_inherited_std_type():
# Request overrides only the amount; the standard_deviation type is
# inherited from addon_params. std/iqr have no (0, 100] ceiling, so this
# must NOT be rejected (the request model deferred the check here).
addon = {
"chunker": {
"semantic_vector": {"breakpoint_threshold_type": "standard_deviation"}
}
}
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 150}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["semantic_vector"]["breakpoint_threshold_amount"] == 150
assert (
chunk_options["semantic_vector"]["breakpoint_threshold_type"]
== "standard_deviation"
)
def test_resolve_rejects_amount_over_100_with_inherited_percentile_type():
# Same partial override, but the effective (inherited) type is percentile,
# which feeds np.percentile and requires the (0, 100] ceiling.
addon = {
"chunker": {"semantic_vector": {"breakpoint_threshold_type": "percentile"}}
}
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 150}}
)
with pytest.raises(ValueError, match="breakpoint_threshold_amount"):
_resolve_text_chunking(cfg, _stub_rag(addon))
def test_resolve_null_size_does_not_erase_inherited_default(monkeypatch):
# An explicit null in the request must not overwrite the resolved size
# with None (which would make the chunker do int(None) in the background).
monkeypatch.setenv("CHUNK_F_SIZE", "640")
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": None}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
# null dropped by the model -> inherited CHUNK_F_SIZE survives, no None.
assert chunk_options["fixed_token"]["chunk_token_size"] == 640
# ---------------------------------------------------------------------------
# 3. Route forwarding + synchronous 422
# ---------------------------------------------------------------------------
class _FwdDocStatus:
async def get_doc_by_file_basename(self, basename):
return None
class _FwdRag:
workspace = "chunk-fwd-test"
addon_params: dict = {}
def __init__(self):
self.doc_status = _FwdDocStatus()
_HEADERS = {"X-API-Key": "test-key"}
def _make_client(monkeypatch, addon_params=None):
"""Build a TestClient whose enqueue-slot guards are no-ops and whose
``pipeline_index_texts`` is a spy recording the forwarded args.
``addon_params`` seeds the rag the routes resolve chunking against; the
handler calls the real ``_resolve_text_chunking`` synchronously, so the
effective-overlap validation runs against this snapshot.
"""
captured: dict = {}
async def _spy(rag, texts, file_sources=None, track_id=None, chunking=None):
captured["texts"] = texts
captured["file_sources"] = file_sources
captured["chunking"] = chunking
async def _noop_reserve(rag):
return False
async def _noop_release(rag):
return None
monkeypatch.setattr(_dr, "pipeline_index_texts", _spy)
monkeypatch.setattr(_dr, "_reserve_enqueue_slot", _noop_reserve)
monkeypatch.setattr(_dr, "_release_enqueue_slot", _noop_release)
rag = _FwdRag()
rag.addon_params = addon_params if addon_params is not None else {}
app = FastAPI()
app.include_router(
create_document_routes(rag, SimpleNamespace(), api_key="test-key")
)
return TestClient(app), captured
def test_insert_text_forwards_chunking(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello world",
"file_source": "a.md",
"chunking": {
"strategy": "recursive_character",
"params": {"chunk_token_size": 1000, "separators": ["X"]},
},
},
)
assert resp.status_code == 200
assert captured["chunking"] is not None
assert captured["chunking"].strategy == "recursive_character"
assert captured["chunking"].params == {
"chunk_token_size": 1000,
"separators": ["X"],
}
def test_insert_texts_forwards_chunking(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/texts",
headers=_HEADERS,
json={
"texts": ["one", "two"],
"file_sources": ["a.md", "b.md"],
"chunking": {"strategy": "semantic_vector", "params": {"buffer_size": 2}},
},
)
assert resp.status_code == 200
assert captured["chunking"].strategy == "semantic_vector"
assert captured["chunking"].params == {"buffer_size": 2}
def test_insert_text_without_chunking_forwards_none(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={"text": "hello", "file_source": "a.md"},
)
assert resp.status_code == 200
assert captured["chunking"] is None
def test_insert_text_returns_422_on_malformed_chunking_without_scheduling(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "recursive_character",
"params": {"separators": "notalist"},
},
},
)
assert resp.status_code == 422
# Body validation fails before the endpoint body runs: no background
# indexing is scheduled, so the spy never fires.
assert captured == {}
def test_insert_text_returns_422_when_size_below_inherited_overlap(monkeypatch):
# chunk_token_size=50 in the request, overlap=100 inherited from the
# rag's addon_params (not in the request). The model can't catch this;
# the handler's synchronous _resolve_text_chunking must, BEFORE any
# background work is scheduled.
addon = {
"chunker": {
"chunk_token_size": 1200,
"fixed_token": {"chunk_overlap_token_size": 100},
}
}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {"strategy": "fixed_token", "params": {"chunk_token_size": 50}},
},
)
assert resp.status_code == 422
assert "chunk_overlap_token_size" in resp.json()["detail"]
# Rejected synchronously: background indexing never scheduled.
assert captured == {}
def test_insert_text_allows_amount_override_inheriting_std_type(monkeypatch):
# Reviewer scenario: deployment sets standard_deviation; a request
# overrides only breakpoint_threshold_amount (> 100). This must be
# accepted (not 422), since std has no (0, 100] ceiling.
addon = {
"chunker": {
"semantic_vector": {"breakpoint_threshold_type": "standard_deviation"}
}
}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": 150},
},
},
)
assert resp.status_code == 200
assert captured["chunking"].params == {"breakpoint_threshold_amount": 150.0}
def test_insert_text_rejects_amount_over_100_inheriting_percentile_type(monkeypatch):
addon = {
"chunker": {"semantic_vector": {"breakpoint_threshold_type": "percentile"}}
}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": 150},
},
},
)
assert resp.status_code == 422
assert "breakpoint_threshold_amount" in resp.json()["detail"]
assert captured == {}
def test_insert_text_rejects_malformed_sentence_split_regex(monkeypatch):
# Malformed regex must 422 at request parse time, before scheduling.
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "semantic_vector",
"params": {"sentence_split_regex": "("},
},
},
)
assert resp.status_code == 422
assert captured == {}
def test_insert_text_drops_explicit_null_param(monkeypatch):
# "chunk_token_size": null must be treated as "inherit" (dropped), so the
# request succeeds and the forwarded params carry no None that would later
# crash the chunker with int(None).
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "fixed_token",
"params": {"chunk_token_size": None, "chunk_overlap_token_size": 50},
},
},
)
assert resp.status_code == 200
assert captured["chunking"].params == {"chunk_overlap_token_size": 50}
def test_insert_text_allows_small_size_for_delimiter_only(monkeypatch):
# Paragraph splitting with a small chunk_token_size: overlap is inherited
# (100) but unused in delimiter-only mode, so this must succeed, not 422.
addon = {"chunker": {"fixed_token": {"chunk_overlap_token_size": 100}}}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "fixed_token",
"params": {
"split_by_character": "\n\n",
"split_by_character_only": True,
"chunk_token_size": 50,
},
},
},
)
assert resp.status_code == 200
assert captured["chunking"].params["chunk_token_size"] == 50
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
import importlib
import sys
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_document_routes = importlib.import_module("lightrag.api.routers.document_routes")
_base = importlib.import_module("lightrag.base")
sys.argv = _original_argv
create_document_routes = _document_routes.create_document_routes
DocProcessingStatus = _base.DocProcessingStatus
DocStatus = _base.DocStatus
DocStatusStorage = _base.DocStatusStorage
pytestmark = pytest.mark.offline
def _doc(status: DocStatus, suffix: str) -> DocProcessingStatus:
return DocProcessingStatus(
content_summary=f"{status.value} summary",
content_length=10,
file_path=f"{suffix}.pdf",
status=status,
created_at="2024-01-01T00:00:00+00:00",
updated_at="2024-01-01T00:00:00+00:00",
metadata={},
)
class _FakeDocStatusStorage:
def __init__(self):
self.docs = {
"processed-doc": _doc(DocStatus.PROCESSED, "processed"),
"parsing-doc": _doc(DocStatus.PARSING, "parsing"),
"analyzing-doc": _doc(DocStatus.ANALYZING, "analyzing"),
}
async def get_docs_paginated(
self,
status_filter=None,
status_filters=None,
page=1,
page_size=50,
sort_field="updated_at",
sort_direction="desc",
):
selected_statuses = DocStatusStorage.resolve_status_filter_values(
status_filter=status_filter,
status_filters=status_filters,
)
documents = [
(doc_id, doc)
for doc_id, doc in self.docs.items()
if selected_statuses is None or doc.status.value in selected_statuses
]
return documents[:page_size], len(documents)
async def get_all_status_counts(self):
return {"processed": 1, "parsing": 1, "analyzing": 1}
_fake_doc_status = _FakeDocStatusStorage()
_app = FastAPI()
_app.include_router(
create_document_routes(
SimpleNamespace(doc_status=_fake_doc_status),
SimpleNamespace(),
api_key="test-key",
)
)
_client = TestClient(_app)
_headers = {"X-API-Key": "test-key"}
def test_documents_paginated_accepts_status_filter():
response = _client.post(
"/documents/paginated",
headers=_headers,
json={
"status_filter": "processed",
"page": 1,
"page_size": 10,
"sort_field": "updated_at",
"sort_direction": "desc",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["pagination"]["total_count"] == 1
assert [doc["id"] for doc in payload["documents"]] == ["processed-doc"]
def test_documents_paginated_status_filters_override_status_filter():
response = _client.post(
"/documents/paginated",
headers=_headers,
json={
"status_filter": "processed",
"status_filters": ["parsing", "analyzing"],
"page": 1,
"page_size": 10,
"sort_field": "updated_at",
"sort_direction": "desc",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["pagination"]["total_count"] == 2
assert [doc["id"] for doc in payload["documents"]] == [
"parsing-doc",
"analyzing-doc",
]
@@ -0,0 +1,298 @@
"""Pipeline-busy guard tests for graph mutation endpoints.
These tests verify that all 7 graph-mutation endpoints refuse to operate
with HTTP 409 while the document pipeline is busy:
- POST /graph/entity/edit (graph_routes)
- POST /graph/relation/edit (graph_routes)
- POST /graph/entity/create (graph_routes)
- POST /graph/relation/create (graph_routes)
- POST /graph/entities/merge (graph_routes)
- DELETE /graph/entity/delete (graph_routes)
- DELETE /graph/relation/delete (graph_routes)
The guard logic itself lives in
``lightrag.api.routers.document_routes.check_pipeline_busy_or_raise`` and is
exercised both at the endpoint integration layer (via monkeypatch, no
shared-storage dependency) and at the unit layer (against a real
``pipeline_status`` namespace).
"""
import importlib
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
# Importing routers loads ``lightrag.api.config`` which parses ``sys.argv`` via
# argparse. Stash argv so pytest's CLI flags don't trip the parser.
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_graph_routes = importlib.import_module("lightrag.api.routers.graph_routes")
_document_routes = importlib.import_module("lightrag.api.routers.document_routes")
sys.argv = _original_argv
create_graph_routes = _graph_routes.create_graph_routes
create_document_routes = _document_routes.create_document_routes
check_pipeline_busy_or_raise = _document_routes.check_pipeline_busy_or_raise
pytestmark = pytest.mark.offline
_API_KEY = "test-key"
_HEADERS = {"X-API-Key": _API_KEY}
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _make_mock_rag() -> SimpleNamespace:
"""Build a minimal LightRAG stand-in with the 7 mutation methods stubbed.
Each ``AsyncMock`` returns a payload shaped enough to satisfy the
endpoint's response model so the idle pass-through test can verify the
full request path. Busy tests don't rely on these return values; the
guard short-circuits before they're reached.
"""
return SimpleNamespace(
workspace="",
aedit_entity=AsyncMock(
return_value={
"entity_name": "Alice",
"description": "updated",
"operation_summary": {
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": "Alice",
"renamed": False,
},
}
),
aedit_relation=AsyncMock(return_value={"description": "updated"}),
acreate_entity=AsyncMock(return_value={"entity_name": "Alice"}),
acreate_relation=AsyncMock(return_value={"src_id": "a", "tgt_id": "b"}),
amerge_entities=AsyncMock(return_value={"merged_entity": "Alice"}),
adelete_by_entity=AsyncMock(
return_value=SimpleNamespace(
status="success", message="deleted", doc_id="ignored"
)
),
adelete_by_relation=AsyncMock(
return_value=SimpleNamespace(
status="success", message="deleted", doc_id="ignored"
)
),
)
def _build_client(rag: SimpleNamespace) -> TestClient:
app = FastAPI()
app.include_router(create_graph_routes(rag, api_key=_API_KEY))
app.include_router(create_document_routes(rag, SimpleNamespace(), api_key=_API_KEY))
return TestClient(app)
async def _force_busy_guard(_rag) -> None:
"""Stand-in for ``check_pipeline_busy_or_raise`` that always refuses."""
raise HTTPException(
status_code=409,
detail=(
"Pipeline is busy with another operation. "
"Wait for the running job to finish before editing "
"the knowledge graph."
),
)
async def _noop_guard(_rag) -> None:
"""Stand-in for ``check_pipeline_busy_or_raise`` that always permits."""
return None
def _patch_guard(monkeypatch, replacement) -> None:
"""Replace the guard reference in BOTH consumer modules.
``graph_routes`` re-binds the name via ``from .document_routes import ...``
so patching only ``document_routes`` would miss the graph endpoints.
"""
monkeypatch.setattr(_graph_routes, "check_pipeline_busy_or_raise", replacement)
monkeypatch.setattr(_document_routes, "check_pipeline_busy_or_raise", replacement)
# ---------------------------------------------------------------------------
# Part A: endpoint integration -- guard refuses with 409
# ---------------------------------------------------------------------------
_ENDPOINTS = [
pytest.param(
"POST",
"/graph/entity/edit",
{"entity_name": "Alice", "updated_data": {"description": "x"}},
id="update_entity",
),
pytest.param(
"POST",
"/graph/relation/edit",
{
"source_id": "Alice",
"target_id": "Bob",
"updated_data": {"description": "x"},
},
id="update_relation",
),
pytest.param(
"POST",
"/graph/entity/create",
{"entity_name": "Alice", "entity_data": {"description": "x"}},
id="create_entity",
),
pytest.param(
"POST",
"/graph/relation/create",
{
"source_entity": "Alice",
"target_entity": "Bob",
"relation_data": {"description": "x"},
},
id="create_relation",
),
pytest.param(
"POST",
"/graph/entities/merge",
{"entities_to_change": ["Alic"], "entity_to_change_into": "Alice"},
id="merge_entities",
),
pytest.param(
"DELETE",
"/graph/entity/delete",
{"entity_name": "Alice"},
id="delete_entity",
),
pytest.param(
"DELETE",
"/graph/relation/delete",
{"source_entity": "Alice", "target_entity": "Bob"},
id="delete_relation",
),
]
@pytest.mark.parametrize("method, path, body", _ENDPOINTS)
def test_endpoint_refuses_with_409_when_pipeline_busy(method, path, body, monkeypatch):
rag = _make_mock_rag()
client = _build_client(rag)
_patch_guard(monkeypatch, _force_busy_guard)
response = client.request(method, path, json=body, headers=_HEADERS)
assert response.status_code == 409, response.text
payload = response.json()
assert "Pipeline is busy" in payload["detail"]
# Guard must short-circuit before the underlying mutation runs.
for attr in (
"aedit_entity",
"aedit_relation",
"acreate_entity",
"acreate_relation",
"amerge_entities",
"adelete_by_entity",
"adelete_by_relation",
):
getattr(rag, attr).assert_not_awaited()
def test_endpoint_passes_through_when_pipeline_idle(monkeypatch):
"""Sanity check: with an idle guard, the request reaches ``rag.aedit_entity``."""
rag = _make_mock_rag()
client = _build_client(rag)
_patch_guard(monkeypatch, _noop_guard)
response = client.post(
"/graph/entity/edit",
json={"entity_name": "Alice", "updated_data": {"description": "x"}},
headers=_HEADERS,
)
assert response.status_code == 200, response.text
rag.aedit_entity.assert_awaited_once()
# ---------------------------------------------------------------------------
# Part B: helper unit -- against real pipeline_status namespace
# ---------------------------------------------------------------------------
async def _with_pipeline_status(action):
"""Bootstrap pipeline_status, run ``action(pipeline_status)``, then tear down.
``initialize_share_data`` is idempotent within a process but
``finalize_share_data`` is required to release the Manager/lock state so
repeated calls in subsequent tests start clean.
"""
from lightrag.kg.shared_storage import (
finalize_share_data,
get_namespace_data,
initialize_pipeline_status,
initialize_share_data,
)
initialize_share_data()
try:
await initialize_pipeline_status(workspace="")
pipeline_status = await get_namespace_data("pipeline_status", workspace="")
await action(pipeline_status)
finally:
finalize_share_data()
async def test_helper_raises_409_when_busy_flag_set():
async def _do(pipeline_status):
pipeline_status["busy"] = True
rag = SimpleNamespace(workspace="")
with pytest.raises(HTTPException) as exc_info:
await check_pipeline_busy_or_raise(rag)
assert exc_info.value.status_code == 409
assert "Pipeline is busy" in exc_info.value.detail
await _with_pipeline_status(_do)
async def test_helper_returns_silently_when_pipeline_idle():
async def _do(pipeline_status):
pipeline_status["busy"] = False
rag = SimpleNamespace(workspace="")
# Should not raise.
await check_pipeline_busy_or_raise(rag)
await _with_pipeline_status(_do)
async def test_helper_is_noop_when_pipeline_status_uninitialized():
"""When pipeline_status namespace was never bootstrapped the helper must pass.
``get_namespace_data`` raises ``PipelineNotInitializedError`` when the
pipeline_status namespace is missing (share data initialized but the
pipeline namespace never created); the helper swallows that error so test
rigs without an end-to-end RAG bootstrap stay green. Mirrors the existing
contract of ``_acquire_destructive_busy``.
"""
from lightrag.kg.shared_storage import (
finalize_share_data,
initialize_share_data,
)
initialize_share_data()
try:
rag = SimpleNamespace(workspace="__never_bootstrapped__")
# Intentionally skip ``initialize_pipeline_status``: helper should
# catch ``PipelineNotInitializedError`` and return silently.
await check_pipeline_busy_or_raise(rag)
finally:
finalize_share_data()
@@ -0,0 +1,174 @@
"""
Verify the /query and /query/stream endpoint response types.
Ensures:
- /query → application/json (no streaming, backward-compatible)
- /query/stream → application/x-ndjson
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
_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):
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
def _build_client():
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
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 TestClient(create_app(args))
finally:
sys.argv = original_argv
class TestQueryRouteJsonOnly:
"""The /query endpoint must stay JSON-only to preserve backward compatibility."""
def test_openapi_spec_declares_json_response(self):
client = _build_client()
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
paths = spec.get("paths", {})
query_path = paths.get("/query", {})
assert query_path, "/query must be in OpenAPI paths"
post_op = query_path.get("post", {})
responses = post_op.get("responses", {})
ok_resp = responses.get("200", {})
content = ok_resp.get("content", {})
# The /query endpoint must declare application/json — NOT ndjson
assert "application/json" in content, (
"/query must declare application/json in OpenAPI spec"
)
assert "application/x-ndjson" not in content, (
"/query must NOT declare application/x-ndjson — streaming belongs to /query/stream"
)
def test_query_route_exists_and_accepts_post(self):
client = _build_client()
# A minimal POST to /query should reach the route (it'll 422 or 500
# since we don't have a real LLM, but it should NOT 404/405)
response = client.post("/query", json={"query": "test", "mode": "mix"})
assert response.status_code not in (
404,
405,
), "/query route must exist and accept POST"
class TestQueryStreamRoute:
"""The /query/stream endpoint must serve application/x-ndjson."""
def test_openapi_spec_declares_ndjson_response(self):
client = _build_client()
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
paths = spec.get("paths", {})
stream_path = paths.get("/query/stream", {})
assert stream_path, "/query/stream must be in OpenAPI paths"
post_op = stream_path.get("post", {})
responses = post_op.get("responses", {})
ok_resp = responses.get("200", {})
content = ok_resp.get("content", {})
# The /query/stream endpoint must declare application/x-ndjson
assert "application/x-ndjson" in content, (
"/query/stream must declare application/x-ndjson in OpenAPI spec"
)
def test_stream_route_exists_and_accepts_post(self):
client = _build_client()
response = client.post("/query/stream", json={"query": "test", "mode": "mix"})
assert response.status_code not in (
404,
405,
), "/query/stream route must exist and accept POST"
class TestQueryStreamResponseContentType:
"""When the mock LLM returns a non-streaming result, /query/stream must
still set the correct Content-Type header."""
def test_stream_response_has_ndjson_content_type(self):
"""Even without a real LLM, the streaming response must carry the
correct media type header."""
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
mock_rag = MagicMock()
mock_result = {
"llm_response": {
"is_streaming": False,
"content": "test response",
},
"data": {"references": []},
}
# Return a coroutine
mock_rag.aquery_llm = MagicMock()
async def _fake_aquery(*a, **kw):
return mock_result
mock_rag.aquery_llm.side_effect = _fake_aquery
with patch("lightrag.api.lightrag_server.LightRAG", return_value=mock_rag):
app = create_app(args)
client = TestClient(app)
response = client.post(
"/query/stream",
json={
"query": "test",
"mode": "mix",
"include_references": True,
},
)
content_type = response.headers.get("content-type", "")
assert "application/x-ndjson" in content_type, (
f"/query/stream must return application/x-ndjson, got: {content_type}"
)
finally:
sys.argv = original_argv
@@ -0,0 +1,48 @@
"""Upload allowlist exposes the native markdown suffixes (.md / .textpack).
``DocumentManager.supported_extensions`` derives the uploadable suffix set
live from the parser registry + routing: a suffix is advertised only when an
unhinted ``x.<suffix>`` routes to an engine that actually supports it. These
tests pin that ``.md`` and ``.textpack`` are uploadable (and routable), and
that ``.textpack`` resolves to the native engine.
"""
import importlib
import sys
import pytest
# document_routes parses argv at import time; guard it like the sibling tests.
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_document_routes = importlib.import_module("lightrag.api.routers.document_routes")
_parser_routing = importlib.import_module("lightrag.parser.routing")
sys.argv = _original_argv
DocumentManager = _document_routes.DocumentManager
resolve_file_parser_engine = _parser_routing.resolve_file_parser_engine
@pytest.fixture
def doc_manager(tmp_path, monkeypatch):
# Clear any ambient LIGHTRAG_PARSER so the default routing applies:
# .md -> legacy (opt-in for native), .textpack -> native.
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
return DocumentManager(str(tmp_path))
def test_textpack_and_md_are_uploadable(doc_manager):
extensions = doc_manager.supported_extensions
assert ".textpack" in extensions
assert ".md" in extensions
def test_bare_textpack_is_supported_file_via_native(doc_manager, monkeypatch):
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
assert doc_manager.is_supported_file("note.textpack") is True
assert resolve_file_parser_engine("note.textpack") == "native"
def test_md_is_supported_file(doc_manager, monkeypatch):
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
assert doc_manager.is_supported_file("doc.md") is True
+187
View File
@@ -0,0 +1,187 @@
"""
Tests for CORS middleware configuration on the LightRAG API server.
LightRAG authenticates exclusively via request headers (Authorization Bearer
token and X-API-Key) and never via cookies or other ambient credentials. The
Fetch spec forbids pairing the wildcard origin "*" with credentialed responses
("Access-Control-Allow-Origin: *" together with
"Access-Control-Allow-Credentials: true"). These tests pin the resulting
behavior:
- When CORS_ORIGINS is the wildcard "*" (the default), credentials are disabled
and the server responds with a clean "Access-Control-Allow-Origin: *" and no
"Access-Control-Allow-Credentials" header.
- When CORS_ORIGINS is an explicit allowlist, the matching origin is reflected
and credentials are enabled.
"""
import sys
from unittest.mock import patch, MagicMock
import pytest
from fastapi.testclient import TestClient
# 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",
"CORS_ORIGINS",
"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 and global config state.
Clear env vars that affect parse_args()/create_app(), then set the minimal
viable defaults (ollama bindings) so create_app's binding validation passes
without touching real services. The global config singleton in
lightrag.api.config is reset before and after each test so neither developer
.env values nor other tests' parsed args leak into our CORS assertions.
"""
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
import lightrag.api.config as config
config._global_args = None
config._initialized = False
yield
config._global_args = None
config._initialized = False
def _build_client(cors_origins, monkeypatch):
"""Create a TestClient for an app configured with the given CORS_ORIGINS."""
from lightrag.api.config import parse_args, initialize_config
monkeypatch.setenv("CORS_ORIGINS", cors_origins)
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
args = parse_args()
finally:
sys.argv = original_argv
# create_app / get_cors_origins read the module-level global_args proxy
# (not the passed args), which otherwise lazily re-parses sys.argv — by then
# polluted with pytest's argv. Pin the parsed args explicitly.
initialize_config(args, force=True)
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(args)
return TestClient(app)
def _preflight(client, origin):
"""Issue a CORS preflight request and return the response."""
return client.options(
"/query",
headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
class TestWildcardCorsDisablesCredentials:
"""With CORS_ORIGINS="*" the config must stay spec-compliant."""
def test_preflight_wildcard_no_credentials(self, monkeypatch):
client = _build_client("*", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert resp.headers["access-control-allow-origin"] == "*"
# Wildcard origin must NOT be paired with credentials.
assert "access-control-allow-credentials" not in resp.headers
def test_simple_request_wildcard_no_credentials(self, monkeypatch):
client = _build_client("*", monkeypatch)
# /health is whitelisted (no auth) so this returns a real response with
# CORS headers attached by the middleware.
resp = client.get("/health", headers={"Origin": "https://evil.example.com"})
assert resp.headers.get("access-control-allow-origin") == "*"
assert "access-control-allow-credentials" not in resp.headers
def test_wildcard_mixed_with_explicit_origin_no_credentials(self, monkeypatch):
# Starlette treats any list containing "*" as allow-all, so a mixed config
# is still allow-all and must NOT enable credentials.
client = _build_client("*,https://app.example.com", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-credentials" not in resp.headers
def test_wildcard_trailing_comma_no_credentials(self, monkeypatch):
# A trailing comma yields ["*", ""]; the empty entry is dropped and the
# wildcard still disables credentials.
client = _build_client("*,", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-credentials" not in resp.headers
class TestEmptyCorsFailsClosed:
"""An explicitly empty CORS_ORIGINS disables cross-origin access (fail closed)."""
def test_empty_string_allows_no_origin(self, monkeypatch):
# CORS_ORIGINS= is an explicit "disable cross-origin" config and must not
# silently widen to "*".
client = _build_client("", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-origin" not in resp.headers
assert "access-control-allow-credentials" not in resp.headers
def test_only_commas_allows_no_origin(self, monkeypatch):
# A value with no real origins (e.g. ",,") also fails closed.
client = _build_client(",,", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-origin" not in resp.headers
assert "access-control-allow-credentials" not in resp.headers
class TestExplicitAllowlistEnablesCredentials:
"""An explicit origin allowlist reflects the origin and allows credentials."""
def test_preflight_allowed_origin_reflected_with_credentials(self, monkeypatch):
client = _build_client(
"https://app.example.com,https://dash.example.com", monkeypatch
)
resp = _preflight(client, "https://app.example.com")
assert resp.headers["access-control-allow-origin"] == "https://app.example.com"
assert resp.headers["access-control-allow-credentials"] == "true"
def test_preflight_disallowed_origin_not_reflected(self, monkeypatch):
client = _build_client("https://app.example.com", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
# A non-allowlisted origin is never echoed back.
assert (
resp.headers.get("access-control-allow-origin")
!= "https://evil.example.com"
)
+272
View File
@@ -0,0 +1,272 @@
"""Tests for credential-gated configuration disclosure on ``GET /health``.
Issue #3294: ``/health`` is whitelisted by default so it answers unauthenticated
liveness probes (HTTP 200). It must therefore reveal sensitive runtime
configuration (filesystem paths, LLM/embedding provider + model + host, storage
backends, queue status, ...) ONLY to authenticated callers. These tests pin
that behavior across the three authentication modes:
- fully open (no AUTH_ACCOUNTS, no API key): everything is open anyway, so the
full configuration is returned to every caller.
- password auth (AUTH_ACCOUNTS): only a valid non-guest JWT (or a configured
API key) unlocks the configuration; anonymous probes get liveness only.
- API-key-only: only a valid X-API-Key unlocks the configuration.
In every case ``/health`` returns HTTP 200 so external liveness probes keep
working.
"""
import sys
from unittest.mock import AsyncMock
import pytest
from fastapi import APIRouter
from fastapi.testclient import TestClient
# Fields that must NEVER appear in an unauthenticated response.
_SENSITIVE_TOP_LEVEL = ("working_directory", "input_directory", "configuration")
# Liveness fields that must always be present (safe; already exposed by the
# unauthenticated /auth-status endpoint or pure liveness signals).
_LIVENESS_FIELDS = ("status", "auth_mode", "core_version", "api_version")
_ENV_VARS_TO_ISOLATE = (
"LLM_BINDING",
"EMBEDDING_BINDING",
"AUTH_ACCOUNTS",
"TOKEN_SECRET",
"LIGHTRAG_API_KEY",
"WHITELIST_PATHS",
"LIGHTRAG_API_PREFIX",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Keep tests hermetic from developer-local .env and global config state."""
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("AUTH_ACCOUNTS", "")
monkeypatch.setenv("LIGHTRAG_API_KEY", "")
monkeypatch.setenv("TOKEN_SECRET", "")
monkeypatch.setenv("WHITELIST_PATHS", "/health,/api/*")
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
import lightrag.api.config as config
config._global_args = None
config._initialized = False
yield
config._global_args = None
config._initialized = False
class _FakeLightRAG:
"""Minimal stand-in implementing the async surface /health touches."""
def __init__(self, *_args, **_kwargs):
pass
def register_role_llm_builder(self, _builder):
return None
def set_role_llm_metadata(self, _role, **_metadata):
return None
def get_llm_role_config(self):
return {}
async def get_llm_queue_status(self, include_base=True):
return {}
async def get_embedding_queue_status(self):
return {}
async def get_rerank_queue_status(self):
return {}
class _FakeOllamaAPI:
def __init__(self, *_args, **_kwargs):
self.router = APIRouter()
def _build_client(monkeypatch, *, api_key=None):
"""Build a /health-capable TestClient with all backend I/O mocked out."""
from lightrag.api.config import parse_args, initialize_config
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
args = parse_args()
finally:
sys.argv = original_argv
if api_key is not None:
args.key = api_key
initialize_config(args, force=True)
import lightrag.api.lightrag_server as lightrag_server
monkeypatch.setattr(lightrag_server, "LightRAG", _FakeLightRAG)
monkeypatch.setattr(lightrag_server, "check_frontend_build", lambda: (True, False))
monkeypatch.setattr(
lightrag_server, "create_document_routes", lambda *_a, **_k: APIRouter()
)
monkeypatch.setattr(
lightrag_server, "create_query_routes", lambda *_a, **_k: APIRouter()
)
monkeypatch.setattr(
lightrag_server, "create_graph_routes", lambda *_a, **_k: APIRouter()
)
monkeypatch.setattr(lightrag_server, "OllamaAPI", _FakeOllamaAPI)
monkeypatch.setattr(
lightrag_server, "get_namespace_data", AsyncMock(return_value={"busy": False})
)
monkeypatch.setattr(lightrag_server, "get_default_workspace", lambda: "default")
monkeypatch.setattr(
lightrag_server,
"cleanup_keyed_lock",
lambda: {"cleanup_performed": {}, "current_status": {}},
)
app = lightrag_server.create_app(args)
return TestClient(app)
def _set_auth_mode(monkeypatch, *, auth_configured):
"""Override the module-level auth flags the /health gate reads at runtime.
Also pin a whitelist that exempts /health so combined_auth keeps returning
200 for anonymous callers (the gate, not combined_auth, hides the config).
"""
import lightrag.api.utils_api as utils_api
monkeypatch.setattr(utils_api, "auth_configured", auth_configured)
monkeypatch.setattr(
utils_api, "whitelist_patterns", [("/health", False), ("/api", True)]
)
def _assert_liveness_only(body):
for field in _LIVENESS_FIELDS:
assert field in body, f"liveness field {field!r} missing"
for field in _SENSITIVE_TOP_LEVEL:
assert field not in body, f"sensitive field {field!r} leaked"
def _assert_full_config(body):
assert "configuration" in body
assert "working_directory" in body
assert "llm_binding" in body["configuration"]
# --------------------------------------------------------------------------- #
# Fully open mode: everything is open, so config is returned to everyone.
# --------------------------------------------------------------------------- #
def test_open_mode_returns_full_config_to_anonymous(monkeypatch):
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=False)
resp = client.get("/health")
assert resp.status_code == 200
_assert_full_config(resp.json())
# --------------------------------------------------------------------------- #
# Password auth: anonymous gets liveness only; a valid token unlocks config.
# --------------------------------------------------------------------------- #
def test_password_mode_anonymous_gets_liveness_only(monkeypatch):
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=True)
resp = client.get("/health")
assert resp.status_code == 200 # liveness probe must stay green
_assert_liveness_only(resp.json())
def test_password_mode_valid_token_unlocks_config(monkeypatch):
import lightrag.api.utils_api as utils_api
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=True)
monkeypatch.setattr(
utils_api.auth_handler,
"validate_token",
lambda token: (
{"username": "admin", "role": "user"}
if token == "valid-user-token"
else (_ for _ in ()).throw(ValueError("bad token"))
),
)
resp = client.get("/health", headers={"Authorization": "Bearer valid-user-token"})
assert resp.status_code == 200
_assert_full_config(resp.json())
def test_password_mode_guest_token_stays_liveness_only(monkeypatch):
import lightrag.api.utils_api as utils_api
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=True)
monkeypatch.setattr(
utils_api.auth_handler,
"validate_token",
lambda token: {"username": "guest", "role": "guest"},
)
resp = client.get("/health", headers={"Authorization": "Bearer guest-token"})
assert resp.status_code == 200
_assert_liveness_only(resp.json())
# --------------------------------------------------------------------------- #
# API-key-only mode: only a valid X-API-Key unlocks the configuration.
# --------------------------------------------------------------------------- #
def test_api_key_mode_anonymous_gets_liveness_only(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=False)
resp = client.get("/health")
assert resp.status_code == 200
_assert_liveness_only(resp.json())
def test_api_key_mode_valid_key_unlocks_config(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=False)
resp = client.get("/health", headers={"X-API-Key": "secret-key"})
assert resp.status_code == 200
_assert_full_config(resp.json())
# --------------------------------------------------------------------------- #
# Combined mode (AUTH_ACCOUNTS + LIGHTRAG_API_KEY): either a valid JWT or a
# valid X-API-Key unlocks the configuration; an anonymous probe gets liveness.
# --------------------------------------------------------------------------- #
def test_combined_mode_anonymous_gets_liveness_only(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=True)
resp = client.get("/health")
assert resp.status_code == 200
_assert_liveness_only(resp.json())
def test_combined_mode_valid_api_key_unlocks_config(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=True)
resp = client.get("/health", headers={"X-API-Key": "secret-key"})
assert resp.status_code == 200
_assert_full_config(resp.json())
+875
View File
@@ -0,0 +1,875 @@
"""
LightRAG Ollama Compatibility Interface Test Script
This script tests the LightRAG's Ollama compatibility interface, including:
1. Basic functionality tests (streaming and non-streaming responses)
2. Query mode tests (local, global, naive, hybrid)
3. Error handling tests (including streaming and non-streaming scenarios)
All responses use the JSON Lines format, complying with the Ollama API specification.
"""
import pytest
import requests
import json
import argparse
import time
from typing import Dict, Any, Optional, List, Callable
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from enum import Enum, auto
class ErrorCode(Enum):
"""Error codes for MCP errors"""
InvalidRequest = auto()
InternalError = auto()
class McpError(Exception):
"""Base exception class for MCP errors"""
def __init__(self, code: ErrorCode, message: str):
self.code = code
self.message = message
super().__init__(message)
DEFAULT_CONFIG = {
"server": {
"host": "localhost",
"port": 9621,
"model": "lightrag:latest",
"timeout": 300,
"max_retries": 1,
"retry_delay": 1,
},
"test_cases": {
"basic": {"query": "唐僧有几个徒弟"},
"generate": {"query": "电视剧西游记导演是谁"},
},
}
# Example conversation history for testing
EXAMPLE_CONVERSATION = [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "你好!我是一个AI助手,很高兴为你服务。"},
{"role": "user", "content": "Who are you?"},
{"role": "assistant", "content": "I'm a Knowledge base query assistant."},
]
class OutputControl:
"""Output control class, manages the verbosity of test output"""
_verbose: bool = False
@classmethod
def set_verbose(cls, verbose: bool) -> None:
cls._verbose = verbose
@classmethod
def is_verbose(cls) -> bool:
return cls._verbose
@dataclass
class ExecutionResult:
"""Test execution result data class"""
name: str
success: bool
duration: float
error: Optional[str] = None
timestamp: str = ""
def __post_init__(self):
if not self.timestamp:
self.timestamp = datetime.now().isoformat()
class ExecutionStats:
"""Test execution statistics"""
def __init__(self):
self.results: List[ExecutionResult] = []
self.start_time = datetime.now()
def add_result(self, result: ExecutionResult):
self.results.append(result)
def export_results(self, path: str = "test_results.json"):
"""Export test results to a JSON file
Args:
path: Output file path
"""
results_data = {
"start_time": self.start_time.isoformat(),
"end_time": datetime.now().isoformat(),
"results": [asdict(r) for r in self.results],
"summary": {
"total": len(self.results),
"passed": sum(1 for r in self.results if r.success),
"failed": sum(1 for r in self.results if not r.success),
"total_duration": sum(r.duration for r in self.results),
},
}
with open(path, "w", encoding="utf-8") as f:
json.dump(results_data, f, ensure_ascii=False, indent=2)
print(f"\nTest results saved to: {path}")
def print_summary(self):
total = len(self.results)
passed = sum(1 for r in self.results if r.success)
failed = total - passed
duration = sum(r.duration for r in self.results)
print("\n=== Test Summary ===")
print(f"Start time: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Total duration: {duration:.2f} seconds")
print(f"Total tests: {total}")
print(f"Passed: {passed}")
print(f"Failed: {failed}")
if failed > 0:
print("\nFailed tests:")
for result in self.results:
if not result.success:
print(f"- {result.name}: {result.error}")
def make_request(
url: str, data: Dict[str, Any], stream: bool = False, check_status: bool = True
) -> requests.Response:
"""Send an HTTP request with retry mechanism
Args:
url: Request URL
data: Request data
stream: Whether to use streaming response
check_status: Whether to check HTTP status code (default: True)
Returns:
requests.Response: Response object
Raises:
requests.exceptions.RequestException: Request failed after all retries
requests.exceptions.HTTPError: HTTP status code is not 200 (when check_status is True)
"""
server_config = CONFIG["server"]
max_retries = server_config["max_retries"]
retry_delay = server_config["retry_delay"]
timeout = server_config["timeout"]
for attempt in range(max_retries):
try:
response = requests.post(url, json=data, stream=stream, timeout=timeout)
if check_status and response.status_code != 200:
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
if attempt == max_retries - 1: # Last retry
raise
print(f"\nRequest failed, retrying in {retry_delay} seconds: {str(e)}")
time.sleep(retry_delay)
def load_config() -> Dict[str, Any]:
"""Load configuration file
First try to load from config.json in the current directory,
if it doesn't exist, use the default configuration
Returns:
Configuration dictionary
"""
config_path = Path("config.json")
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
return DEFAULT_CONFIG
def print_json_response(data: Dict[str, Any], title: str = "", indent: int = 2) -> None:
"""Format and print JSON response data
Args:
data: Data dictionary to print
title: Title to print
indent: Number of spaces for JSON indentation
"""
if OutputControl.is_verbose():
if title:
print(f"\n=== {title} ===")
print(json.dumps(data, ensure_ascii=False, indent=indent))
# Global configuration
CONFIG = load_config()
def get_base_url(endpoint: str = "chat") -> str:
"""Return the base URL for specified endpoint
Args:
endpoint: API endpoint name (chat or generate)
Returns:
Complete URL for the endpoint
"""
server = CONFIG["server"]
return f"http://{server['host']}:{server['port']}/api/{endpoint}"
def create_chat_request_data(
content: str,
stream: bool = False,
model: str = None,
conversation_history: List[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""Create chat request data
Args:
content: User message content
stream: Whether to use streaming response
model: Model name
conversation_history: List of previous conversation messages
Returns:
Dictionary containing complete chat request data
"""
messages = conversation_history or []
messages.append({"role": "user", "content": content})
return {
"model": model or CONFIG["server"]["model"],
"messages": messages,
"stream": stream,
}
def create_generate_request_data(
prompt: str,
system: str = None,
stream: bool = False,
model: str = None,
options: Dict[str, Any] = None,
) -> Dict[str, Any]:
"""Create generate request data
Args:
prompt: Generation prompt
system: System prompt
stream: Whether to use streaming response
model: Model name
options: Additional options
Returns:
Dictionary containing complete generate request data
"""
data = {
"model": model or CONFIG["server"]["model"],
"prompt": prompt,
"stream": stream,
}
if system:
data["system"] = system
if options:
data["options"] = options
return data
# Global test statistics
STATS = ExecutionStats()
def run_test(func: Callable, name: str) -> None:
"""Run a test and record the results
Args:
func: Test function
name: Test name
"""
start_time = time.time()
try:
func()
duration = time.time() - start_time
STATS.add_result(ExecutionResult(name, True, duration))
except Exception as e:
duration = time.time() - start_time
STATS.add_result(ExecutionResult(name, False, duration, str(e)))
raise
@pytest.mark.integration
@pytest.mark.requires_api
def test_non_stream_chat() -> None:
"""Test non-streaming call to /api/chat endpoint"""
url = get_base_url()
# Send request with conversation history
data = create_chat_request_data(
CONFIG["test_cases"]["basic"]["query"],
stream=False,
conversation_history=EXAMPLE_CONVERSATION,
)
response = make_request(url, data)
# Print response
if OutputControl.is_verbose():
print("\n=== Non-streaming call response ===")
response_json = response.json()
# Print response content
print_json_response(
{"model": response_json["model"], "message": response_json["message"]},
"Response content",
)
@pytest.mark.integration
@pytest.mark.requires_api
def test_stream_chat() -> None:
"""Test streaming call to /api/chat endpoint
Use JSON Lines format to process streaming responses, each line is a complete JSON object.
Response format:
{
"model": "lightrag:latest",
"created_at": "2024-01-15T00:00:00Z",
"message": {
"role": "assistant",
"content": "Partial response content",
"images": null
},
"done": false
}
The last message will contain performance statistics, with done set to true.
"""
url = get_base_url()
# Send request with conversation history
data = create_chat_request_data(
CONFIG["test_cases"]["basic"]["query"],
stream=True,
conversation_history=EXAMPLE_CONVERSATION,
)
response = make_request(url, data, stream=True)
if OutputControl.is_verbose():
print("\n=== Streaming call response ===")
output_buffer = []
try:
for line in response.iter_lines():
if line: # Skip empty lines
try:
# Decode and parse JSON
data = json.loads(line.decode("utf-8"))
if data.get("done", True): # If it's the completion marker
if (
"total_duration" in data
): # Final performance statistics message
# print_json_response(data, "Performance statistics")
break
else: # Normal content message
message = data.get("message", {})
content = message.get("content", "")
if content: # Only collect non-empty content
output_buffer.append(content)
print(
content, end="", flush=True
) # Print content in real-time
except json.JSONDecodeError:
print("Error decoding JSON from response line")
finally:
response.close() # Ensure the response connection is closed
# Print a newline
print()
@pytest.mark.integration
@pytest.mark.requires_api
def test_query_modes() -> None:
"""Test different query mode prefixes
Supported query modes:
- /local: Local retrieval mode, searches only in highly relevant documents
- /global: Global retrieval mode, searches across all documents
- /naive: Naive mode, does not use any optimization strategies
- /hybrid: Hybrid mode (default), combines multiple strategies
- /mix: Mix mode
Each mode will return responses in the same format, but with different retrieval strategies.
"""
url = get_base_url()
modes = ["local", "global", "naive", "hybrid", "mix"]
for mode in modes:
if OutputControl.is_verbose():
print(f"\n=== Testing /{mode} mode ===")
data = create_chat_request_data(
f"/{mode} {CONFIG['test_cases']['basic']['query']}", stream=False
)
# Send request
response = make_request(url, data)
response_json = response.json()
# Print response content
print_json_response(
{"model": response_json["model"], "message": response_json["message"]}
)
def create_error_test_data(error_type: str) -> Dict[str, Any]:
"""Create request data for error testing
Args:
error_type: Error type, supported:
- empty_messages: Empty message list
- invalid_role: Invalid role field
- missing_content: Missing content field
Returns:
Request dictionary containing error data
"""
error_data = {
"empty_messages": {"model": "lightrag:latest", "messages": [], "stream": True},
"invalid_role": {
"model": "lightrag:latest",
"messages": [{"invalid_role": "user", "content": "Test message"}],
"stream": True,
},
"missing_content": {
"model": "lightrag:latest",
"messages": [{"role": "user"}],
"stream": True,
},
}
return error_data.get(error_type, error_data["empty_messages"])
@pytest.mark.integration
@pytest.mark.requires_api
def test_stream_error_handling() -> None:
"""Test error handling for streaming responses
Test scenarios:
1. Empty message list
2. Message format error (missing required fields)
Error responses should be returned immediately without establishing a streaming connection.
The status code should be 4xx, and detailed error information should be returned.
"""
url = get_base_url()
if OutputControl.is_verbose():
print("\n=== Testing streaming response error handling ===")
# Test empty message list
if OutputControl.is_verbose():
print("\n--- Testing empty message list (streaming) ---")
data = create_error_test_data("empty_messages")
response = make_request(url, data, stream=True, check_status=False)
print(f"Status code: {response.status_code}")
if response.status_code != 200:
print_json_response(response.json(), "Error message")
response.close()
# Test invalid role field
if OutputControl.is_verbose():
print("\n--- Testing invalid role field (streaming) ---")
data = create_error_test_data("invalid_role")
response = make_request(url, data, stream=True, check_status=False)
print(f"Status code: {response.status_code}")
if response.status_code != 200:
print_json_response(response.json(), "Error message")
response.close()
# Test missing content field
if OutputControl.is_verbose():
print("\n--- Testing missing content field (streaming) ---")
data = create_error_test_data("missing_content")
response = make_request(url, data, stream=True, check_status=False)
print(f"Status code: {response.status_code}")
if response.status_code != 200:
print_json_response(response.json(), "Error message")
response.close()
@pytest.mark.integration
@pytest.mark.requires_api
def test_error_handling() -> None:
"""Test error handling for non-streaming responses
Test scenarios:
1. Empty message list
2. Message format error (missing required fields)
Error response format:
{
"detail": "Error description"
}
All errors should return appropriate HTTP status codes and clear error messages.
"""
url = get_base_url()
if OutputControl.is_verbose():
print("\n=== Testing error handling ===")
# Test empty message list
if OutputControl.is_verbose():
print("\n--- Testing empty message list ---")
data = create_error_test_data("empty_messages")
data["stream"] = False # Change to non-streaming mode
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
# Test invalid role field
if OutputControl.is_verbose():
print("\n--- Testing invalid role field ---")
data = create_error_test_data("invalid_role")
data["stream"] = False # Change to non-streaming mode
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
# Test missing content field
if OutputControl.is_verbose():
print("\n--- Testing missing content field ---")
data = create_error_test_data("missing_content")
data["stream"] = False # Change to non-streaming mode
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
@pytest.mark.integration
@pytest.mark.requires_api
def test_non_stream_generate() -> None:
"""Test non-streaming call to /api/generate endpoint"""
url = get_base_url("generate")
data = create_generate_request_data(
CONFIG["test_cases"]["generate"]["query"], stream=False
)
# Send request
response = make_request(url, data)
# Print response
if OutputControl.is_verbose():
print("\n=== Non-streaming generate response ===")
response_json = response.json()
# Print response content
print(json.dumps(response_json, ensure_ascii=False, indent=2))
@pytest.mark.integration
@pytest.mark.requires_api
def test_stream_generate() -> None:
"""Test streaming call to /api/generate endpoint"""
url = get_base_url("generate")
data = create_generate_request_data(
CONFIG["test_cases"]["generate"]["query"], stream=True
)
# Send request and get streaming response
response = make_request(url, data, stream=True)
if OutputControl.is_verbose():
print("\n=== Streaming generate response ===")
output_buffer = []
try:
for line in response.iter_lines():
if line: # Skip empty lines
try:
# Decode and parse JSON
data = json.loads(line.decode("utf-8"))
if data.get("done", True): # If it's the completion marker
if (
"total_duration" in data
): # Final performance statistics message
break
else: # Normal content message
content = data.get("response", "")
if content: # Only collect non-empty content
output_buffer.append(content)
print(
content, end="", flush=True
) # Print content in real-time
except json.JSONDecodeError:
print("Error decoding JSON from response line")
finally:
response.close() # Ensure the response connection is closed
# Print a newline
print()
@pytest.mark.integration
@pytest.mark.requires_api
def test_generate_with_system() -> None:
"""Test generate with system prompt"""
url = get_base_url("generate")
data = create_generate_request_data(
CONFIG["test_cases"]["generate"]["query"],
system="你是一个知识渊博的助手",
stream=False,
)
# Send request
response = make_request(url, data)
# Print response
if OutputControl.is_verbose():
print("\n=== Generate with system prompt response ===")
response_json = response.json()
# Print response content
print_json_response(
{
"model": response_json["model"],
"response": response_json["response"],
"done": response_json["done"],
},
"Response content",
)
@pytest.mark.integration
@pytest.mark.requires_api
def test_generate_error_handling() -> None:
"""Test error handling for generate endpoint"""
url = get_base_url("generate")
# Test empty prompt
if OutputControl.is_verbose():
print("\n=== Testing empty prompt ===")
data = create_generate_request_data("", stream=False)
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
# Test invalid options
if OutputControl.is_verbose():
print("\n=== Testing invalid options ===")
data = create_generate_request_data(
CONFIG["test_cases"]["basic"]["query"],
options={"invalid_option": "value"},
stream=False,
)
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
@pytest.mark.integration
@pytest.mark.requires_api
def test_generate_concurrent() -> None:
"""Test concurrent generate requests"""
import asyncio
import aiohttp
from contextlib import asynccontextmanager
@asynccontextmanager
async def get_session():
async with aiohttp.ClientSession() as session:
yield session
async def make_request(session, prompt: str, request_id: int):
url = get_base_url("generate")
data = create_generate_request_data(prompt, stream=False)
try:
async with session.post(url, json=data) as response:
if response.status != 200:
error_msg = (
f"Request {request_id} failed with status {response.status}"
)
if OutputControl.is_verbose():
print(f"\n{error_msg}")
raise McpError(ErrorCode.InternalError, error_msg)
result = await response.json()
if "error" in result:
error_msg = (
f"Request {request_id} returned error: {result['error']}"
)
if OutputControl.is_verbose():
print(f"\n{error_msg}")
raise McpError(ErrorCode.InternalError, error_msg)
return result
except Exception as e:
error_msg = f"Request {request_id} failed: {str(e)}"
if OutputControl.is_verbose():
print(f"\n{error_msg}")
raise McpError(ErrorCode.InternalError, error_msg)
async def run_concurrent_requests():
prompts = ["第一个问题", "第二个问题", "第三个问题", "第四个问题", "第五个问题"]
async with get_session() as session:
tasks = [
make_request(session, prompt, i + 1) for i, prompt in enumerate(prompts)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
success_results = []
error_messages = []
for i, result in enumerate(results):
if isinstance(result, Exception):
error_messages.append(f"Request {i + 1} failed: {str(result)}")
else:
success_results.append((i + 1, result))
if error_messages:
for req_id, result in success_results:
if OutputControl.is_verbose():
print(f"\nRequest {req_id} succeeded:")
print_json_response(result)
error_summary = "\n".join(error_messages)
raise McpError(
ErrorCode.InternalError,
f"Some concurrent requests failed:\n{error_summary}",
)
return results
if OutputControl.is_verbose():
print("\n=== Testing concurrent generate requests ===")
# Run concurrent requests
try:
results = asyncio.run(run_concurrent_requests())
# all success, print out results
for i, result in enumerate(results, 1):
print(f"\nRequest {i} result:")
print_json_response(result)
except McpError:
# error message already printed
raise
def get_test_cases() -> Dict[str, Callable]:
"""Get all available test cases
Returns:
A dictionary mapping test names to test functions
"""
return {
"non_stream": test_non_stream_chat,
"stream": test_stream_chat,
"modes": test_query_modes,
"errors": test_error_handling,
"stream_errors": test_stream_error_handling,
"non_stream_generate": test_non_stream_generate,
"stream_generate": test_stream_generate,
"generate_with_system": test_generate_with_system,
"generate_errors": test_generate_error_handling,
"generate_concurrent": test_generate_concurrent,
}
def create_default_config():
"""Create a default configuration file"""
config_path = Path("config.json")
if not config_path.exists():
with open(config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)
print(f"Default configuration file created: {config_path}")
def parse_args() -> argparse.Namespace:
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description="LightRAG Ollama Compatibility Interface Testing",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Configuration file (config.json):
{
"server": {
"host": "localhost", # Server address
"port": 9621, # Server port
"model": "lightrag:latest" # Default model name
},
"test_cases": {
"basic": {
"query": "Test query", # Basic query text
"stream_query": "Stream query" # Stream query text
}
}
}
""",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Silent mode, only display test result summary",
)
parser.add_argument(
"-a",
"--ask",
type=str,
help="Specify query content, which will override the query settings in the configuration file",
)
parser.add_argument(
"--init-config", action="store_true", help="Create default configuration file"
)
parser.add_argument(
"--output",
type=str,
default="",
help="Test result output file path, default is not to output to a file",
)
parser.add_argument(
"--tests",
nargs="+",
choices=list(get_test_cases().keys()) + ["all"],
default=["all"],
help="Test cases to run, options: %(choices)s. Use 'all' to run all tests except error tests)",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
# Set output mode
OutputControl.set_verbose(not args.quiet)
# If query content is specified, update the configuration
if args.ask:
CONFIG["test_cases"]["basic"]["query"] = args.ask
# If specified to create a configuration file
if args.init_config:
create_default_config()
exit(0)
test_cases = get_test_cases()
try:
if "all" in args.tests:
# Run all tests except error handling tests
if OutputControl.is_verbose():
print("\n【Chat API Tests】")
run_test(test_non_stream_chat, "Non-streaming Chat Test")
run_test(test_stream_chat, "Streaming Chat Test")
run_test(test_query_modes, "Chat Query Mode Test")
if OutputControl.is_verbose():
print("\n【Generate API Tests】")
run_test(test_non_stream_generate, "Non-streaming Generate Test")
run_test(test_stream_generate, "Streaming Generate Test")
run_test(test_generate_with_system, "Generate with System Prompt Test")
run_test(test_generate_concurrent, "Generate Concurrent Test")
else:
# Run specified tests
for test_name in args.tests:
if OutputControl.is_verbose():
print(f"\n【Running Test: {test_name}")
run_test(test_cases[test_name], test_name)
except Exception as e:
print(f"\nAn error occurred: {str(e)}")
finally:
# Print test statistics
STATS.print_summary()
# If an output file path is specified, export the results
if args.output:
STATS.export_results(args.output)
+655
View File
@@ -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"