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