chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
View File
+272
View File
@@ -0,0 +1,272 @@
import importlib
import sys
from types import SimpleNamespace
import bcrypt
import pytest
from fastapi import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from lightrag.api.passwords import BCRYPT_PASSWORD_PREFIX, hash_password
from lightrag.tools.hash_password import main as hash_password_main
from lightrag.utils import logger as lightrag_logger
def import_real_api_module(module_name: str):
sys.modules.pop(module_name, None)
package_name, _, child_name = module_name.rpartition(".")
package = sys.modules.get(package_name)
if package is not None and hasattr(package, child_name):
delattr(package, child_name)
return importlib.import_module(module_name)
@pytest.fixture
def auth_module(monkeypatch):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret="test-jwt-secret",
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="admin:admin_pass",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
module = import_real_api_module("lightrag.api.auth")
module = importlib.reload(module)
yield module
sys.modules.pop("lightrag.api.auth", None)
def build_bcrypt_value(password: str) -> str:
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"
def test_verify_plaintext_password(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"admin": "admin_pass"}
assert handler.verify_password("admin", "admin_pass")
assert not handler.verify_password("admin", "wrong_pass")
def test_verify_prefixed_bcrypt_password(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"user": build_bcrypt_value("user_pass")}
assert handler.verify_password("user", "user_pass")
assert not handler.verify_password("user", "wrong_pass")
def test_plaintext_password_with_bcrypt_prefix_stays_plaintext(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"user": "$2b$not-a-real-hash"}
assert handler.verify_password("user", "$2b$not-a-real-hash")
assert not handler.verify_password("user", "anything-else")
def test_invalid_auth_accounts_raises(monkeypatch):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret="test-jwt-secret",
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="admin",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
with pytest.raises(ValueError, match="AUTH_ACCOUNTS must use"):
import_real_api_module("lightrag.api.auth")
sys.modules.pop("lightrag.api.auth", None)
def test_initialize_config_rejects_default_token_secret_with_auth_accounts():
config = import_real_api_module("lightrag.api.config")
insecure_args = SimpleNamespace(
auth_accounts="admin:admin_pass",
token_secret=config.DEFAULT_TOKEN_SECRET,
)
with pytest.raises(ValueError, match="TOKEN_SECRET must be explicitly set"):
config.initialize_config(insecure_args, force=True)
def test_initialize_config_allows_custom_token_secret_with_auth_accounts():
config = import_real_api_module("lightrag.api.config")
secure_args = SimpleNamespace(
auth_accounts="admin:admin_pass",
token_secret="custom-jwt-secret",
)
initialized = config.initialize_config(secure_args, force=True)
assert initialized is secure_args
def test_guest_tokens_fall_back_to_default_secret_when_token_secret_missing(
monkeypatch,
):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret=None,
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
warning_messages = []
def capture_warning(message):
warning_messages.append(message)
monkeypatch.setattr(lightrag_logger, "warning", capture_warning)
module = import_real_api_module("lightrag.api.auth")
module = importlib.reload(module)
handler = module.AuthHandler()
token = handler.create_token("guest", role="guest")
token_info = handler.validate_token(token)
assert handler.secret == config.DEFAULT_TOKEN_SECRET
assert token_info["username"] == "guest"
assert token_info["role"] == "guest"
assert any(
"Falling back to the default guest-mode JWT secret" in msg
for msg in warning_messages
)
sys.modules.pop("lightrag.api.auth", None)
def _load_api_key_only_modules(monkeypatch):
"""Import auth + utils_api in the API-key-only profile.
API-key-only = an API key is set but AUTH_ACCOUNTS is empty, so
``auth_configured`` is False and AuthHandler falls back to the public
DEFAULT_TOKEN_SECRET. Returns (config, auth, utils_api).
"""
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret=None, # -> AuthHandler falls back to DEFAULT_TOKEN_SECRET
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="", # no password accounts -> auth_configured is False
whitelist_paths="/health", # consumed by utils_api at import time
token_auto_renew=False,
)
monkeypatch.setattr(config, "global_args", mock_global_args)
auth = import_real_api_module("lightrag.api.auth")
auth = importlib.reload(auth)
utils_api = import_real_api_module("lightrag.api.utils_api")
return config, auth, utils_api
async def test_combined_auth_rejects_guest_token_in_api_key_mode(monkeypatch):
"""A forged/obtained guest token must not bypass the X-API-Key check.
Regression for GHSA-f4vv-55c2-5789 / GHSA-xr5c-v5r6-c9f9: in the
API-key-only profile an anonymous caller can mint a guest JWT with the
public default secret (or fetch one from /auth-status) and previously
short-circuited authorization before the API key was ever checked.
"""
config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
try:
assert utils_api.auth_configured is False
assert auth.auth_handler.secret == config.DEFAULT_TOKEN_SECRET
forged_guest = auth.auth_handler.create_token(username="guest", role="guest")
api_key = "operator-secret-key"
dependency = utils_api.get_combined_auth_dependency(api_key=api_key)
request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
response = SimpleNamespace(headers={})
# Forged guest token, no API key -> rejected (was HTTP 200 before the fix).
with pytest.raises(HTTPException) as rejected:
await dependency(
request=request,
response=response,
token=forged_guest,
api_key_header_value=None,
)
assert rejected.value.status_code == HTTP_403_FORBIDDEN
# Guest token together with the real API key -> allowed (the WebUI sends
# both Authorization and X-API-Key headers).
result = await dependency(
request=request,
response=response,
token=forged_guest,
api_key_header_value=api_key,
)
assert result is None
finally:
sys.modules.pop("lightrag.api.utils_api", None)
sys.modules.pop("lightrag.api.auth", None)
async def test_combined_auth_allows_guest_token_when_fully_open(monkeypatch):
"""Fully-open mode (no API key, no AUTH_ACCOUNTS) keeps accepting guest tokens.
Guards against over-fixing: the bypass fix must not break zero-config guest
access, which is the intended convenience mode.
"""
_config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
try:
guest_token = auth.auth_handler.create_token(username="guest", role="guest")
dependency = utils_api.get_combined_auth_dependency(api_key=None)
request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
response = SimpleNamespace(headers={})
result = await dependency(
request=request,
response=response,
token=guest_token,
api_key_header_value=None,
)
assert result is None
finally:
sys.modules.pop("lightrag.api.utils_api", None)
sys.modules.pop("lightrag.api.auth", None)
def test_hash_password_returns_prefixed_value(auth_module):
hashed = hash_password("new_password")
assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
assert bcrypt.checkpw("new_password".encode("utf-8"), raw_hash.encode("utf-8"))
def test_hash_password_cli_outputs_auth_accounts_entry(capsys):
exit_code = hash_password_main(["--username", "admin", "secret"])
assert exit_code == 0
output = capsys.readouterr().out.strip()
username, hashed = output.split(":", 1)
assert username == "admin"
assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
assert bcrypt.checkpw("secret".encode("utf-8"), raw_hash.encode("utf-8"))
+403
View File
@@ -0,0 +1,403 @@
"""
Pytest unit tests for token auto-renewal functionality
Tests:
1. Backend token renewal logic
2. Rate limiting for token renewals
3. Token renewal state tracking
"""
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import Mock
from fastapi import Response
import time
# Create a simple token renewal cache for testing
_token_renewal_cache = {}
_RENEWAL_MIN_INTERVAL = 60
@pytest.mark.offline
class TestTokenRenewal:
"""Tests for token auto-renewal logic"""
@pytest.fixture
def mock_auth_handler(self):
"""Mock authentication handler"""
handler = Mock()
handler.guest_expire_hours = 24
handler.expire_hours = 24
handler.create_token = Mock(return_value="new-token-12345")
return handler
@pytest.fixture
def mock_global_args(self):
"""Mock global configuration"""
args = Mock()
args.token_auto_renew = True
args.token_renew_threshold = 0.5
return args
@pytest.fixture
def mock_token_info_guest(self):
"""Mock token info for guest user"""
# Token with 10 hours remaining (below 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
return {
"username": "guest",
"role": "guest",
"exp": exp_time,
"metadata": {"auth_mode": "disabled"},
}
@pytest.fixture
def mock_token_info_user(self):
"""Mock token info for regular user"""
# Token with 10 hours remaining (below 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
return {
"username": "testuser",
"role": "user",
"exp": exp_time,
"metadata": {"auth_mode": "enabled"},
}
@pytest.fixture
def mock_token_info_above_threshold(self):
"""Mock token info with time above renewal threshold"""
# Token with 20 hours remaining (above 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=20)
return {
"username": "testuser",
"role": "user",
"exp": exp_time,
"metadata": {"auth_mode": "enabled"},
}
def test_token_renewal_when_below_threshold(
self, mock_auth_handler, mock_global_args, mock_token_info_user
):
"""Test that token is renewed when remaining time < threshold"""
# Use global cache
global _token_renewal_cache
# Clear cache
_token_renewal_cache.clear()
response = Mock(spec=Response)
response.headers = {}
# Simulate the renewal logic
expire_time = mock_token_info_user["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
role = mock_token_info_user["role"]
total_hours = (
mock_auth_handler.expire_hours
if role == "user"
else mock_auth_handler.guest_expire_hours
)
total_seconds = total_hours * 3600
# Should renew because remaining_seconds < total_seconds * 0.5
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is True
# Simulate renewal
username = mock_token_info_user["username"]
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time - last_renewal
# Should pass rate limit (first renewal)
assert time_since_last_renewal >= 60 or last_renewal == 0
# Perform renewal
new_token = mock_auth_handler.create_token(
username=username, role=role, metadata=mock_token_info_user["metadata"]
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = current_time
# Verify
assert "X-New-Token" in response.headers
assert response.headers["X-New-Token"] == "new-token-12345"
assert username in _token_renewal_cache
def test_token_no_renewal_when_above_threshold(
self, mock_auth_handler, mock_global_args, mock_token_info_above_threshold
):
"""Test that token is NOT renewed when remaining time > threshold"""
response = Mock(spec=Response)
response.headers = {}
expire_time = mock_token_info_above_threshold["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
mock_token_info_above_threshold["role"]
total_hours = mock_auth_handler.expire_hours
total_seconds = total_hours * 3600
# Should NOT renew because remaining_seconds > total_seconds * 0.5
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is False
# No renewal should happen
assert "X-New-Token" not in response.headers
def test_token_renewal_disabled(
self, mock_auth_handler, mock_global_args, mock_token_info_user
):
"""Test that no renewal happens when TOKEN_AUTO_RENEW=false"""
mock_global_args.token_auto_renew = False
response = Mock(spec=Response)
response.headers = {}
# Auto-renewal is disabled, so even if below threshold, no renewal
if not mock_global_args.token_auto_renew:
# Skip renewal logic
pass
assert "X-New-Token" not in response.headers
def test_token_renewal_for_guest_mode(
self, mock_auth_handler, mock_global_args, mock_token_info_guest
):
"""Test that guest tokens are renewed correctly"""
# Use global cache
global _token_renewal_cache
_token_renewal_cache.clear()
response = Mock(spec=Response)
response.headers = {}
expire_time = mock_token_info_guest["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
role = mock_token_info_guest["role"]
total_hours = mock_auth_handler.guest_expire_hours
total_seconds = total_hours * 3600
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is True
# Renewal for guest
username = mock_token_info_guest["username"]
new_token = mock_auth_handler.create_token(
username=username, role=role, metadata=mock_token_info_guest["metadata"]
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = time.time()
assert "X-New-Token" in response.headers
assert username in _token_renewal_cache
@pytest.mark.offline
class TestRateLimiting:
"""Tests for token renewal rate limiting"""
@pytest.fixture
def mock_auth_handler(self):
"""Mock authentication handler"""
handler = Mock()
handler.expire_hours = 24
handler.create_token = Mock(return_value="new-token-12345")
return handler
def test_rate_limit_prevents_rapid_renewals(self, mock_auth_handler):
"""Test that second renewal within 60s is blocked"""
# Use global cache and constant
global _token_renewal_cache, _RENEWAL_MIN_INTERVAL
username = "testuser"
_token_renewal_cache.clear()
# First renewal
current_time_1 = time.time()
_token_renewal_cache[username] = current_time_1
response_1 = Mock(spec=Response)
response_1.headers = {}
response_1.headers["X-New-Token"] = "new-token-12345"
# Immediate second renewal attempt (within 60s)
current_time_2 = time.time() # Almost same time
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time_2 - last_renewal
# Should be blocked by rate limit
assert time_since_last_renewal < _RENEWAL_MIN_INTERVAL
response_2 = Mock(spec=Response)
response_2.headers = {}
# No new token should be issued
if time_since_last_renewal < _RENEWAL_MIN_INTERVAL:
# Rate limited, skip renewal
pass
assert "X-New-Token" not in response_2.headers
def test_rate_limit_allows_renewal_after_interval(self, mock_auth_handler):
"""Test that renewal succeeds after 60s interval"""
# Use global cache and constant
global _token_renewal_cache, _RENEWAL_MIN_INTERVAL
username = "testuser"
_token_renewal_cache.clear()
# First renewal at time T
first_renewal_time = time.time() - 61 # 61 seconds ago
_token_renewal_cache[username] = first_renewal_time
# Second renewal attempt now
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time - last_renewal
# Should pass rate limit (>60s elapsed)
assert time_since_last_renewal >= _RENEWAL_MIN_INTERVAL
response = Mock(spec=Response)
response.headers = {}
if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:
new_token = mock_auth_handler.create_token(
username=username, role="user", metadata={}
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = current_time
assert "X-New-Token" in response.headers
assert response.headers["X-New-Token"] == "new-token-12345"
def test_rate_limit_per_user(self, mock_auth_handler):
"""Test that different users have independent rate limits"""
# Use global cache
global _token_renewal_cache
_token_renewal_cache.clear()
user1 = "user1"
user2 = "user2"
current_time = time.time()
# User1 gets renewal
_token_renewal_cache[user1] = current_time
# User2 should still be able to get renewal (independent cache)
last_renewal_user2 = _token_renewal_cache.get(user2, 0)
assert last_renewal_user2 == 0 # No previous renewal
# User2 can renew
_token_renewal_cache[user2] = current_time
# Both users should have entries
assert user1 in _token_renewal_cache
assert user2 in _token_renewal_cache
assert _token_renewal_cache[user1] == _token_renewal_cache[user2]
@pytest.mark.offline
class TestTokenExpirationCalculation:
"""Tests for token expiration time calculation"""
def test_expiration_extraction_from_jwt(self):
"""Test extracting expiration time from JWT token"""
import base64
import json
# Create a mock JWT payload
exp_timestamp = int(
(datetime.now(timezone.utc) + timedelta(hours=24)).timestamp()
)
payload = {"sub": "testuser", "role": "user", "exp": exp_timestamp}
# Encode as base64 (simulating JWT structure: header.payload.signature)
payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode()
mock_token = f"header.{payload_b64}.signature"
# Simulate extraction
parts = mock_token.split(".")
assert len(parts) == 3
decoded_payload = json.loads(base64.b64decode(parts[1]))
assert decoded_payload["exp"] == exp_timestamp
assert decoded_payload["sub"] == "testuser"
def test_remaining_time_calculation(self):
"""Test calculation of remaining token time"""
# Token expires in 10 hours
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
now = datetime.now(timezone.utc)
remaining_seconds = (exp_time - now).total_seconds()
# Should be approximately 10 hours (36000 seconds)
assert 35990 < remaining_seconds < 36010
# Calculate percentage remaining (for 24-hour token)
total_seconds = 24 * 3600
percentage_remaining = remaining_seconds / total_seconds
# Should be approximately 41.67% remaining
assert 0.41 < percentage_remaining < 0.42
def test_threshold_comparison(self):
"""Test threshold-based renewal decision"""
threshold = 0.5
total_hours = 24
total_seconds = total_hours * 3600
# Scenario 1: 10 hours remaining -> should renew
remaining_seconds_1 = 10 * 3600
should_renew_1 = remaining_seconds_1 < total_seconds * threshold
assert should_renew_1 is True
# Scenario 2: 20 hours remaining -> should NOT renew
remaining_seconds_2 = 20 * 3600
should_renew_2 = remaining_seconds_2 < total_seconds * threshold
assert should_renew_2 is False
# Scenario 3: Exactly 12 hours remaining (at threshold) -> should NOT renew
remaining_seconds_3 = 12 * 3600
should_renew_3 = remaining_seconds_3 < total_seconds * threshold
assert should_renew_3 is False
@pytest.mark.offline
def test_renewal_cache_cleanup():
"""Test that renewal cache can be cleared"""
# Use global cache
global _token_renewal_cache
# Clear first
_token_renewal_cache.clear()
# Add some entries
_token_renewal_cache["user1"] = time.time()
_token_renewal_cache["user2"] = time.time()
assert len(_token_renewal_cache) == 2
# Clear cache
_token_renewal_cache.clear()
assert len(_token_renewal_cache) == 0
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])