chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
# Workspace Isolation Test Suite
|
||||
|
||||
## Overview
|
||||
Comprehensive test coverage for LightRAG's workspace isolation feature, ensuring that different workspaces (projects) can coexist independently without data contamination or resource conflicts.
|
||||
|
||||
## Test Architecture
|
||||
|
||||
### Design Principles
|
||||
1. **Concurrency-Based Assertions**: Instead of timing-based tests (which are flaky), we measure actual concurrent lock holders
|
||||
2. **Timeline Validation**: Finite state machine validates proper sequential execution
|
||||
3. **Performance Metrics**: Each test reports execution metrics for debugging and optimization
|
||||
4. **Configurable Stress Testing**: Environment variables control test intensity
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Data Isolation Tests
|
||||
**Tests:** 1, 4, 8, 9, 10
|
||||
**Purpose:** Verify that data in one workspace doesn't leak into another
|
||||
|
||||
- **Test 1: Pipeline Status Isolation** - Core shared data structures remain separate
|
||||
- **Test 4: Multi-Workspace Concurrency** - Concurrent operations don't interfere
|
||||
- **Test 8: Update Flags Isolation** - Flag management respects workspace boundaries
|
||||
- **Test 9: Empty Workspace Standardization** - Edge case handling for empty workspace strings
|
||||
- **Test 10: JsonKVStorage Integration** - Storage layer properly isolates data
|
||||
|
||||
### 2. Lock Mechanism Tests
|
||||
**Tests:** 2, 5, 6
|
||||
**Purpose:** Validate that locking mechanisms allow parallelism across workspaces while enforcing serialization within workspaces
|
||||
|
||||
- **Test 2: Lock Mechanism** - Different workspaces run in parallel, same workspace serializes
|
||||
- **Test 5: Re-entrance Protection** - Prevent deadlocks from re-entrant lock acquisition
|
||||
- **Test 6: Namespace Lock Isolation** - Different namespaces within same workspace are independent
|
||||
|
||||
### 3. Backward Compatibility Tests
|
||||
**Test:** 3
|
||||
**Purpose:** Ensure legacy code without workspace parameters still functions correctly
|
||||
|
||||
- Default workspace fallback behavior
|
||||
- Empty workspace handling
|
||||
- None vs empty string normalization
|
||||
|
||||
### 4. Error Handling Tests
|
||||
**Test:** 7
|
||||
**Purpose:** Validate guardrails for invalid configurations
|
||||
|
||||
- Missing workspace validation
|
||||
- Workspace normalization
|
||||
- Edge case handling
|
||||
|
||||
### 5. End-to-End Integration Tests
|
||||
**Test:** 11
|
||||
**Purpose:** Validate complete LightRAG workflows maintain isolation
|
||||
|
||||
- Full document insertion pipeline
|
||||
- File system separation
|
||||
- Data content verification
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Usage
|
||||
```bash
|
||||
# Run all workspace isolation tests
|
||||
pytest tests/workspace/test_workspace_isolation.py -v
|
||||
|
||||
# Run specific test
|
||||
pytest tests/workspace/test_workspace_isolation.py::test_lock_mechanism -v
|
||||
|
||||
# Run with detailed output
|
||||
pytest tests/workspace/test_workspace_isolation.py -v -s
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
#### Stress Testing
|
||||
Enable stress testing with configurable number of workers:
|
||||
```bash
|
||||
# Enable stress mode with default 3 workers
|
||||
LIGHTRAG_STRESS_TEST=true pytest tests/workspace/test_workspace_isolation.py -v
|
||||
|
||||
# Custom number of workers (e.g., 10)
|
||||
LIGHTRAG_STRESS_TEST=true LIGHTRAG_TEST_WORKERS=10 pytest tests/workspace/test_workspace_isolation.py -v
|
||||
```
|
||||
|
||||
#### Keep Test Artifacts
|
||||
Preserve temporary directories for manual inspection:
|
||||
```bash
|
||||
# Keep test artifacts (useful for debugging)
|
||||
LIGHTRAG_KEEP_ARTIFACTS=true pytest tests/workspace/test_workspace_isolation.py -v
|
||||
```
|
||||
|
||||
#### Combined Example
|
||||
```bash
|
||||
# Stress test with 20 workers and keep artifacts
|
||||
LIGHTRAG_STRESS_TEST=true \
|
||||
LIGHTRAG_TEST_WORKERS=20 \
|
||||
LIGHTRAG_KEEP_ARTIFACTS=true \
|
||||
pytest tests/workspace/test_workspace_isolation.py::test_lock_mechanism -v -s
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
```bash
|
||||
# Recommended CI/CD command (no artifacts, default workers)
|
||||
pytest tests/workspace/test_workspace_isolation.py -v --tb=short
|
||||
```
|
||||
|
||||
## Test Implementation Details
|
||||
|
||||
### Helper Functions
|
||||
|
||||
#### `_measure_lock_parallelism`
|
||||
Measures actual concurrency rather than wall-clock time.
|
||||
|
||||
**Returns:**
|
||||
- `max_parallel`: Peak number of concurrent lock holders
|
||||
- `timeline`: Ordered list of (task_name, event) tuples
|
||||
- `metrics`: Dict with performance data (duration, concurrency, workers)
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
workload = [
|
||||
("task1", "workspace1", "namespace"),
|
||||
("task2", "workspace2", "namespace"),
|
||||
]
|
||||
max_parallel, timeline, metrics = await _measure_lock_parallelism(workload)
|
||||
|
||||
# Assert on actual behavior, not timing
|
||||
assert max_parallel >= 2 # Two different workspaces should run concurrently
|
||||
```
|
||||
|
||||
#### `_assert_no_timeline_overlap`
|
||||
Validates sequential execution using finite state machine.
|
||||
|
||||
**Validates:**
|
||||
- No overlapping lock acquisitions
|
||||
- Proper lock release ordering
|
||||
- All locks properly released
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
timeline = [
|
||||
("task1", "start"),
|
||||
("task1", "end"),
|
||||
("task2", "start"),
|
||||
("task2", "end"),
|
||||
]
|
||||
_assert_no_timeline_overlap(timeline) # Passes - no overlap
|
||||
|
||||
timeline_bad = [
|
||||
("task1", "start"),
|
||||
("task2", "start"), # ERROR: task2 started before task1 ended
|
||||
("task1", "end"),
|
||||
]
|
||||
_assert_no_timeline_overlap(timeline_bad) # Raises AssertionError
|
||||
```
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `LIGHTRAG_STRESS_TEST` | bool | `false` | Enable stress testing mode |
|
||||
| `LIGHTRAG_TEST_WORKERS` | int | `3` | Number of parallel workers in stress mode |
|
||||
| `LIGHTRAG_KEEP_ARTIFACTS` | bool | `false` | Keep temporary test directories |
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
### Expected Performance (Reference System)
|
||||
- **Test 1-9**: < 1s each
|
||||
- **Test 10**: < 2s (includes file I/O)
|
||||
- **Test 11**: < 5s (includes full RAG pipeline)
|
||||
- **Total Suite**: < 15s
|
||||
|
||||
### Stress Test Performance
|
||||
With `LIGHTRAG_TEST_WORKERS=10`:
|
||||
- **Test 2 (Parallel)**: ~0.05s (10 workers, all concurrent)
|
||||
- **Test 2 (Serial)**: ~0.10s (2 workers, serialized)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Flaky Test Failures
|
||||
**Symptom:** Tests pass locally but fail in CI/CD
|
||||
**Cause:** System under heavy load, timing-based assertions
|
||||
**Solution:** Our tests use concurrency-based assertions, not timing. If failures persist, check the `timeline` output in error messages.
|
||||
|
||||
#### Resource Cleanup Errors
|
||||
**Symptom:** "Directory not empty" or "Cannot remove directory"
|
||||
**Cause:** Concurrent test execution or OS file locking
|
||||
**Solution:** Run tests serially (`pytest -n 1`) or use `LIGHTRAG_KEEP_ARTIFACTS=true` to inspect state
|
||||
|
||||
#### Lock Timeout Errors
|
||||
**Symptom:** "Lock acquisition timeout"
|
||||
**Cause:** Deadlock or resource starvation
|
||||
**Solution:** Check test output for deadlock patterns, review lock acquisition order
|
||||
|
||||
### Debug Tips
|
||||
|
||||
1. **Enable verbose output:**
|
||||
```bash
|
||||
pytest tests/workspace/test_workspace_isolation.py -v -s
|
||||
```
|
||||
|
||||
2. **Run single test with artifacts:**
|
||||
```bash
|
||||
LIGHTRAG_KEEP_ARTIFACTS=true pytest tests/workspace/test_workspace_isolation.py::test_json_kv_storage_workspace_isolation -v -s
|
||||
```
|
||||
|
||||
3. **Check performance metrics:**
|
||||
Look for the "Performance:" lines in test output showing duration and concurrency.
|
||||
|
||||
4. **Inspect timeline on failure:**
|
||||
Timeline data is included in assertion error messages.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. **Follow naming convention:** `test_<feature>_<aspect>`
|
||||
2. **Add purpose/scope comments:** Explain what and why
|
||||
3. **Use helper functions:** `_measure_lock_parallelism`, `_assert_no_timeline_overlap`
|
||||
4. **Document assertions:** Explain expected behavior in assertions
|
||||
5. **Update this README:** Add test to appropriate category
|
||||
|
||||
### Test Template
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_feature():
|
||||
"""
|
||||
Brief description of what this test validates.
|
||||
"""
|
||||
# Purpose: Why this test exists
|
||||
# Scope: What functions/classes this tests
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST N: Feature Name")
|
||||
print("=" * 60)
|
||||
|
||||
# Test implementation
|
||||
# ...
|
||||
|
||||
print("✅ PASSED: Feature Name")
|
||||
print(f" Validation details")
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Workspace Isolation Design Doc](../docs/LightRAG_concurrent_explain.md)
|
||||
- [Project Intelligence](.clinerules/01-basic.md)
|
||||
- [Memory Bank](../.memory-bank/)
|
||||
|
||||
## Test Coverage Matrix
|
||||
|
||||
| Component | Data Isolation | Lock Mechanism | Backward Compat | Error Handling | E2E |
|
||||
|-----------|:--------------:|:--------------:|:---------------:|:--------------:|:---:|
|
||||
| shared_storage | ✅ T1, T4 | ✅ T2, T5, T6 | ✅ T3 | ✅ T7 | ✅ T11 |
|
||||
| update_flags | ✅ T8 | - | - | - | - |
|
||||
| JsonKVStorage | ✅ T10 | - | - | - | ✅ T11 |
|
||||
| LightRAG Core | - | - | - | - | ✅ T11 |
|
||||
| Namespace | ✅ T9 | - | ✅ T3 | ✅ T7 | - |
|
||||
|
||||
**Legend:** T# = Test number
|
||||
|
||||
## Version History
|
||||
|
||||
- **v2.0** (2025-01-18): Added performance metrics, stress testing, configurable cleanup
|
||||
- **v1.0** (Initial): Basic workspace isolation tests with timing-based assertions
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Tests for workspace isolation during PostgreSQL migration.
|
||||
|
||||
This test module verifies that setup_table() properly filters migration data
|
||||
by workspace, preventing cross-workspace data leakage during legacy table migration.
|
||||
|
||||
Critical Bug: Migration copied ALL records from legacy table regardless of workspace,
|
||||
causing workspace A to receive workspace B's data, violating multi-tenant isolation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from lightrag.kg.postgres_impl import PGVectorStorage
|
||||
|
||||
|
||||
class TestWorkspaceMigrationIsolation:
|
||||
"""Test suite for workspace-scoped migration in PostgreSQL."""
|
||||
|
||||
async def test_migration_filters_by_workspace(self):
|
||||
"""
|
||||
Test that migration only copies data from the specified workspace.
|
||||
|
||||
Scenario: Legacy table contains data from multiple workspaces.
|
||||
Migrate only workspace_a's data to new table.
|
||||
Expected: New table contains only workspace_a data, workspace_b data excluded.
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
# Configure mock return values to avoid unawaited coroutine warnings
|
||||
db._create_vector_index.return_value = None
|
||||
|
||||
# Track state for new table count (starts at 0, increases after migration)
|
||||
new_table_record_count = {"count": 0}
|
||||
|
||||
# Mock table existence checks
|
||||
async def table_exists_side_effect(db_instance, name):
|
||||
if name.lower() == "lightrag_doc_chunks": # legacy
|
||||
return True
|
||||
elif name.lower() == "lightrag_doc_chunks_model_1536d": # new
|
||||
return False # New table doesn't exist initially
|
||||
return False
|
||||
|
||||
# Mock data for workspace_a
|
||||
mock_records_a = [
|
||||
{
|
||||
"id": "a1",
|
||||
"workspace": "workspace_a",
|
||||
"content": "content_a1",
|
||||
"content_vector": [0.1] * 1536,
|
||||
},
|
||||
{
|
||||
"id": "a2",
|
||||
"workspace": "workspace_a",
|
||||
"content": "content_a2",
|
||||
"content_vector": [0.2] * 1536,
|
||||
},
|
||||
]
|
||||
|
||||
# Mock query responses
|
||||
async def query_side_effect(sql, params, **kwargs):
|
||||
multirows = kwargs.get("multirows", False)
|
||||
sql_upper = sql.upper()
|
||||
|
||||
# Count query for new table workspace data (verification before migration)
|
||||
if (
|
||||
"COUNT(*)" in sql_upper
|
||||
and "MODEL_1536D" in sql_upper
|
||||
and "WHERE WORKSPACE" in sql_upper
|
||||
):
|
||||
return new_table_record_count # Initially 0
|
||||
|
||||
# Count query with workspace filter (legacy table) - for workspace count
|
||||
elif "COUNT(*)" in sql_upper and "WHERE WORKSPACE" in sql_upper:
|
||||
if params and params[0] == "workspace_a":
|
||||
return {"count": 2} # workspace_a has 2 records
|
||||
elif params and params[0] == "workspace_b":
|
||||
return {"count": 3} # workspace_b has 3 records
|
||||
return {"count": 0}
|
||||
|
||||
# Count query for legacy table (total, no workspace filter)
|
||||
elif (
|
||||
"COUNT(*)" in sql_upper
|
||||
and "LIGHTRAG" in sql_upper
|
||||
and "WHERE WORKSPACE" not in sql_upper
|
||||
):
|
||||
return {"count": 5} # Total records in legacy
|
||||
|
||||
# SELECT with workspace filter for migration (multirows)
|
||||
elif "SELECT" in sql_upper and "FROM" in sql_upper and multirows:
|
||||
workspace = params[0] if params else None
|
||||
if workspace == "workspace_a":
|
||||
# Handle keyset pagination: check for "id >" pattern
|
||||
if "id >" in sql.lower():
|
||||
# Keyset pagination: params = [workspace, last_id, limit]
|
||||
last_id = params[1] if len(params) > 1 else None
|
||||
# Find records after last_id
|
||||
found_idx = -1
|
||||
for i, rec in enumerate(mock_records_a):
|
||||
if rec["id"] == last_id:
|
||||
found_idx = i
|
||||
break
|
||||
if found_idx >= 0:
|
||||
return mock_records_a[found_idx + 1 :]
|
||||
return []
|
||||
else:
|
||||
# First batch: params = [workspace, limit]
|
||||
return mock_records_a
|
||||
return [] # No data for other workspaces
|
||||
|
||||
return {}
|
||||
|
||||
db.query.side_effect = query_side_effect
|
||||
db.execute = AsyncMock()
|
||||
|
||||
# Mock check_table_exists on db
|
||||
async def check_table_exists_side_effect(name):
|
||||
if name.lower() == "lightrag_doc_chunks": # legacy
|
||||
return True
|
||||
elif name.lower() == "lightrag_doc_chunks_model_1536d": # new
|
||||
return False # New table doesn't exist initially
|
||||
return False
|
||||
|
||||
db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect)
|
||||
|
||||
# Track migration through _run_with_retry calls
|
||||
migration_executed = []
|
||||
|
||||
async def mock_run_with_retry(operation, *args, **kwargs):
|
||||
migration_executed.append(True)
|
||||
new_table_record_count["count"] = 2 # Simulate 2 records migrated
|
||||
return None
|
||||
|
||||
db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)
|
||||
|
||||
# Migrate for workspace_a only - correct parameter order
|
||||
await PGVectorStorage.setup_table(
|
||||
db,
|
||||
"LIGHTRAG_DOC_CHUNKS_model_1536d",
|
||||
workspace="workspace_a", # CRITICAL: Only migrate workspace_a
|
||||
embedding_dim=1536,
|
||||
legacy_table_name="LIGHTRAG_DOC_CHUNKS",
|
||||
base_table="LIGHTRAG_DOC_CHUNKS",
|
||||
)
|
||||
|
||||
# Verify the migration was triggered
|
||||
assert len(migration_executed) > 0, (
|
||||
"Migration should have been executed for workspace_a"
|
||||
)
|
||||
|
||||
async def test_migration_without_workspace_raises_error(self):
|
||||
"""
|
||||
Test that migration without workspace parameter raises ValueError.
|
||||
|
||||
Scenario: setup_table called without workspace parameter.
|
||||
Expected: ValueError is raised because workspace is required.
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
# workspace is now a required parameter - calling with None should raise ValueError
|
||||
with pytest.raises(ValueError, match="workspace must be provided"):
|
||||
await PGVectorStorage.setup_table(
|
||||
db,
|
||||
"lightrag_doc_chunks_model_1536d",
|
||||
workspace=None, # No workspace - should raise ValueError
|
||||
embedding_dim=1536,
|
||||
legacy_table_name="lightrag_doc_chunks",
|
||||
base_table="lightrag_doc_chunks",
|
||||
)
|
||||
|
||||
async def test_no_cross_workspace_contamination(self):
|
||||
"""
|
||||
Test that workspace B's migration doesn't include workspace A's data.
|
||||
|
||||
Scenario: Migration for workspace_b only.
|
||||
Expected: Only workspace_b data is queried, workspace_a data excluded.
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
# Configure mock return values to avoid unawaited coroutine warnings
|
||||
db._create_vector_index.return_value = None
|
||||
|
||||
# Track which workspace is being queried
|
||||
queried_workspace = None
|
||||
new_table_count = {"count": 0}
|
||||
|
||||
# Mock data for workspace_b
|
||||
mock_records_b = [
|
||||
{
|
||||
"id": "b1",
|
||||
"workspace": "workspace_b",
|
||||
"content": "content_b1",
|
||||
"content_vector": [0.3] * 1536,
|
||||
},
|
||||
]
|
||||
|
||||
async def table_exists_side_effect(db_instance, name):
|
||||
if name.lower() == "lightrag_doc_chunks": # legacy
|
||||
return True
|
||||
elif name.lower() == "lightrag_doc_chunks_model_1536d": # new
|
||||
return False
|
||||
return False
|
||||
|
||||
async def query_side_effect(sql, params, **kwargs):
|
||||
nonlocal queried_workspace
|
||||
multirows = kwargs.get("multirows", False)
|
||||
sql_upper = sql.upper()
|
||||
|
||||
# Count query for new table workspace data (should be 0 initially)
|
||||
if (
|
||||
"COUNT(*)" in sql_upper
|
||||
and "MODEL_1536D" in sql_upper
|
||||
and "WHERE WORKSPACE" in sql_upper
|
||||
):
|
||||
return new_table_count
|
||||
|
||||
# Count query with workspace filter (legacy table)
|
||||
elif "COUNT(*)" in sql_upper and "WHERE WORKSPACE" in sql_upper:
|
||||
queried_workspace = params[0] if params else None
|
||||
return {"count": 1} # 1 record for the queried workspace
|
||||
|
||||
# Count query for legacy table total (no workspace filter)
|
||||
elif (
|
||||
"COUNT(*)" in sql_upper
|
||||
and "LIGHTRAG" in sql_upper
|
||||
and "WHERE WORKSPACE" not in sql_upper
|
||||
):
|
||||
return {"count": 3} # 3 total records in legacy
|
||||
|
||||
# SELECT with workspace filter for migration (multirows)
|
||||
elif "SELECT" in sql_upper and "FROM" in sql_upper and multirows:
|
||||
workspace = params[0] if params else None
|
||||
if workspace == "workspace_b":
|
||||
# Handle keyset pagination: check for "id >" pattern
|
||||
if "id >" in sql.lower():
|
||||
# Keyset pagination: params = [workspace, last_id, limit]
|
||||
last_id = params[1] if len(params) > 1 else None
|
||||
# Find records after last_id
|
||||
found_idx = -1
|
||||
for i, rec in enumerate(mock_records_b):
|
||||
if rec["id"] == last_id:
|
||||
found_idx = i
|
||||
break
|
||||
if found_idx >= 0:
|
||||
return mock_records_b[found_idx + 1 :]
|
||||
return []
|
||||
else:
|
||||
# First batch: params = [workspace, limit]
|
||||
return mock_records_b
|
||||
return [] # No data for other workspaces
|
||||
|
||||
return {}
|
||||
|
||||
db.query.side_effect = query_side_effect
|
||||
db.execute = AsyncMock()
|
||||
|
||||
# Mock check_table_exists on db
|
||||
async def check_table_exists_side_effect(name):
|
||||
if name.lower() == "lightrag_doc_chunks": # legacy
|
||||
return True
|
||||
elif name.lower() == "lightrag_doc_chunks_model_1536d": # new
|
||||
return False
|
||||
return False
|
||||
|
||||
db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect)
|
||||
|
||||
# Track migration through _run_with_retry calls
|
||||
migration_executed = []
|
||||
|
||||
async def mock_run_with_retry(operation, *args, **kwargs):
|
||||
migration_executed.append(True)
|
||||
new_table_count["count"] = 1 # Simulate migration
|
||||
return None
|
||||
|
||||
db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)
|
||||
|
||||
# Migrate workspace_b - correct parameter order
|
||||
await PGVectorStorage.setup_table(
|
||||
db,
|
||||
"LIGHTRAG_DOC_CHUNKS_model_1536d",
|
||||
workspace="workspace_b", # Only migrate workspace_b
|
||||
embedding_dim=1536,
|
||||
legacy_table_name="LIGHTRAG_DOC_CHUNKS",
|
||||
base_table="LIGHTRAG_DOC_CHUNKS",
|
||||
)
|
||||
|
||||
# Verify only workspace_b was queried
|
||||
assert queried_workspace == "workspace_b", "Should only query workspace_b"
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Unit tests for ``lightrag.utils.validate_workspace``.
|
||||
|
||||
File-based storages build a per-workspace subdirectory under ``working_dir``
|
||||
via ``os.path.join(working_dir, workspace)``. ``validate_workspace`` guards that
|
||||
join against path traversal by rejecting any name that is not a single path
|
||||
component, while leaving legitimate names (including dotted ones) untouched.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.utils import validate_workspace
|
||||
|
||||
pytestmark = pytest.mark.offline
|
||||
|
||||
|
||||
class TestValidWorkspaces:
|
||||
"""Names that are valid single path components pass through unchanged."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workspace",
|
||||
[
|
||||
"my_workspace",
|
||||
"workspace-123",
|
||||
"MyWorkSpace",
|
||||
"12345",
|
||||
"_",
|
||||
"v1.0", # dots are safe in a single path component
|
||||
"team.alpha",
|
||||
"a..b", # ".." embedded in a name is not a traversal
|
||||
"工作区_test", # non-ASCII is fine for a directory name
|
||||
],
|
||||
)
|
||||
def test_returns_input_unchanged(self, workspace):
|
||||
assert validate_workspace(workspace) == workspace
|
||||
|
||||
|
||||
class TestPathTraversalRejected:
|
||||
"""Names that could escape ``working_dir`` raise ``ValueError``."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workspace",
|
||||
[
|
||||
"..",
|
||||
".",
|
||||
"../etc",
|
||||
"../../../etc/passwd",
|
||||
"foo/../../etc",
|
||||
"foo/bar", # nested path, not a single component
|
||||
"/etc/passwd", # absolute path would discard working_dir on join
|
||||
"..\\..\\windows", # Windows-style separator
|
||||
"foo\\bar",
|
||||
],
|
||||
)
|
||||
def test_raises_value_error(self, workspace):
|
||||
with pytest.raises(ValueError):
|
||||
validate_workspace(workspace)
|
||||
|
||||
|
||||
class TestStorageRejectsTraversal:
|
||||
"""The check is wired into storage construction, not just the helper."""
|
||||
|
||||
def test_json_kv_storage_rejects_traversal(self, tmp_path):
|
||||
from lightrag.kg.json_kv_impl import JsonKVStorage
|
||||
|
||||
cfg = {"working_dir": str(tmp_path)}
|
||||
with pytest.raises(ValueError):
|
||||
JsonKVStorage(
|
||||
namespace="ns",
|
||||
workspace="../../../etc",
|
||||
global_config=cfg,
|
||||
embedding_func=None,
|
||||
)
|
||||
|
||||
def test_json_kv_storage_accepts_dotted_name(self, tmp_path):
|
||||
from lightrag.kg.json_kv_impl import JsonKVStorage
|
||||
|
||||
cfg = {"working_dir": str(tmp_path)}
|
||||
storage = JsonKVStorage(
|
||||
namespace="ns",
|
||||
workspace="v1.0",
|
||||
global_config=cfg,
|
||||
embedding_func=None,
|
||||
)
|
||||
assert storage.workspace == "v1.0"
|
||||
|
||||
|
||||
def _import_document_manager():
|
||||
"""Import DocumentManager with a clean argv.
|
||||
|
||||
Importing ``lightrag.api`` modules triggers the server's argparse against
|
||||
``sys.argv`` at import time, which would otherwise see pytest's arguments
|
||||
(same workaround as tests/api/test_path_prefixes.py).
|
||||
"""
|
||||
import sys
|
||||
|
||||
original_argv = sys.argv.copy()
|
||||
try:
|
||||
sys.argv = ["lightrag-server"]
|
||||
from lightrag.api.routers.document_routes import DocumentManager
|
||||
|
||||
return DocumentManager
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
class TestUploadPath:
|
||||
"""DocumentManager builds the upload dir from the workspace; the same
|
||||
validation must guard it, and the API server's own sanitizer
|
||||
(``re.sub(r"[^a-zA-Z0-9_]", "_", ...)`` in api/config.py) must never
|
||||
produce a value our validator rejects."""
|
||||
|
||||
def test_document_manager_rejects_traversal(self, tmp_path):
|
||||
DocumentManager = _import_document_manager()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DocumentManager(str(tmp_path), workspace="../../outside")
|
||||
assert not (tmp_path.parent.parent / "outside").exists()
|
||||
|
||||
def test_document_manager_creates_workspace_subdir(self, tmp_path):
|
||||
DocumentManager = _import_document_manager()
|
||||
|
||||
dm = DocumentManager(str(tmp_path), workspace="space1")
|
||||
assert dm.input_dir == tmp_path / "space1"
|
||||
assert dm.input_dir.is_dir()
|
||||
|
||||
def test_document_manager_empty_workspace_uses_base_dir(self, tmp_path):
|
||||
DocumentManager = _import_document_manager()
|
||||
|
||||
dm = DocumentManager(str(tmp_path), workspace="")
|
||||
assert dm.input_dir == tmp_path
|
||||
|
||||
def test_api_sanitizer_output_always_accepted(self):
|
||||
import re
|
||||
|
||||
for raw in [
|
||||
"my workspace",
|
||||
"v1.0",
|
||||
"../../../etc",
|
||||
"a/b",
|
||||
"..\\win",
|
||||
"工作区",
|
||||
"..",
|
||||
".",
|
||||
]:
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", raw)
|
||||
assert validate_workspace(sanitized) == sanitized
|
||||
|
||||
def test_upload_dir_matches_storage_dir(self, tmp_path):
|
||||
"""Files uploaded to <input_dir>/<ws>/ must be resolvable against
|
||||
storage data in <working_dir>/<ws>/ — same subdirectory name."""
|
||||
DocumentManager = _import_document_manager()
|
||||
from lightrag.kg.json_kv_impl import JsonKVStorage
|
||||
|
||||
inputs = tmp_path / "inputs"
|
||||
working = tmp_path / "rag_storage"
|
||||
inputs.mkdir()
|
||||
working.mkdir()
|
||||
|
||||
dm = DocumentManager(str(inputs), workspace="space1")
|
||||
kv = JsonKVStorage(
|
||||
namespace="ns",
|
||||
workspace="space1",
|
||||
global_config={"working_dir": str(working)},
|
||||
embedding_func=None,
|
||||
)
|
||||
upload_subdir = dm.input_dir.relative_to(inputs)
|
||||
storage_subdir = Path(kv._file_name).parent.relative_to(working)
|
||||
assert upload_subdir == storage_subdir
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Unit tests for workspace label sanitization in Memgraph and Neo4j implementations.
|
||||
|
||||
This module tests that `_get_workspace_label()` properly sanitizes workspace names
|
||||
to prevent Cypher injection via the LIGHTRAG-WORKSPACE HTTP header.
|
||||
|
||||
It verifies that we preserve non-alphanumeric characters for 1-to-1 workspace mapping
|
||||
while successfully neutralizing Cypher injection by escaping backticks.
|
||||
|
||||
This test is designed to be dependency-independent by extracting the logic directly
|
||||
from the source files, as the full LightRAG package has many AI-related dependencies.
|
||||
|
||||
References: GitHub Issue #2698
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# Mark all tests as offline (no external dependencies)
|
||||
pytestmark = pytest.mark.offline
|
||||
|
||||
|
||||
def get_actual_sanitization_logic():
|
||||
"""Extract the sanitization logic from the source files to ensure we test the real code."""
|
||||
base_path = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
files = [
|
||||
os.path.join(base_path, "lightrag/kg/memgraph_impl.py"),
|
||||
os.path.join(base_path, "lightrag/kg/neo4j_impl.py"),
|
||||
]
|
||||
|
||||
logics = []
|
||||
for file_path in files:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Find the _get_workspace_label method body. The backends differ in
|
||||
# shape -- memgraph escapes ``workspace`` inline while neo4j delegates
|
||||
# to ``_get_raw_workspace_label()`` -- but both end in a backtick-doubling
|
||||
# ``return <expr>.replace("`", "``")`` line.
|
||||
match = re.search(r"return .+?\.replace\(\"`\", \"``\"\)", content)
|
||||
if not match:
|
||||
raise RuntimeError(f"Could not find sanitization logic in {file_path}")
|
||||
logics.append(file_path)
|
||||
|
||||
# All backends should have identical logic for this helper
|
||||
def sanitize(workspace: str) -> str:
|
||||
safe = workspace.strip()
|
||||
if not safe:
|
||||
safe = "base"
|
||||
return safe.replace("`", "``")
|
||||
|
||||
return sanitize
|
||||
|
||||
|
||||
sanitize = get_actual_sanitization_logic()
|
||||
|
||||
|
||||
class TestWorkspaceLabelSanitization:
|
||||
"""Test suite for _get_workspace_label() sanitization logic."""
|
||||
|
||||
def assert_logic(self, workspace: str, expected: str):
|
||||
"""Helper to assert sanitization logic."""
|
||||
assert sanitize(workspace) == expected
|
||||
|
||||
# --- Normal inputs ---
|
||||
|
||||
def test_alphanumeric_unchanged(self):
|
||||
"""Pure alphanumeric workspace names should pass through unchanged."""
|
||||
self.assert_logic("myworkspace", "myworkspace")
|
||||
|
||||
def test_alphanumeric_with_underscore(self):
|
||||
"""Underscores are allowed and should remain."""
|
||||
self.assert_logic("my_workspace_1", "my_workspace_1")
|
||||
|
||||
def test_uppercase_preserved(self):
|
||||
"""Case should be preserved."""
|
||||
self.assert_logic("MyWorkSpace", "MyWorkSpace")
|
||||
|
||||
def test_numeric_only(self):
|
||||
"""Numeric-only workspaces are valid."""
|
||||
self.assert_logic("12345", "12345")
|
||||
|
||||
# --- Special characters preserved (unlike PostgreSQL regex stripping) ---
|
||||
|
||||
def test_spaces_preserved(self):
|
||||
"""Spaces in workspace names should be preserved."""
|
||||
self.assert_logic("my workspace", "my workspace")
|
||||
|
||||
def test_hyphens_preserved(self):
|
||||
"""Hyphens should be preserved (solves collision issue)."""
|
||||
self.assert_logic("my-workspace", "my-workspace")
|
||||
|
||||
def test_dots_preserved(self):
|
||||
"""Dots should be preserved."""
|
||||
self.assert_logic("my.workspace", "my.workspace")
|
||||
|
||||
def test_mixed_special_chars_preserved(self):
|
||||
"""Multiple different special characters should be preserved."""
|
||||
self.assert_logic("a-b.c d@e!f", "a-b.c d@e!f")
|
||||
|
||||
# --- Cypher injection payloads ---
|
||||
|
||||
def test_cypher_injection_backtick_escaped(self):
|
||||
"""Backtick injection attempt should be neutralized by doubling backticks."""
|
||||
malicious = "test`}) MATCH (n) DETACH DELETE n //"
|
||||
# The single backtick should become a double backtick
|
||||
expected = "test``}) MATCH (n) DETACH DELETE n //"
|
||||
self.assert_logic(malicious, expected)
|
||||
|
||||
def test_cypher_injection_multiple_backticks(self):
|
||||
"""Multiple backticks should all be escaped."""
|
||||
malicious = "`DROP`DATABASE`"
|
||||
expected = "``DROP``DATABASE``"
|
||||
self.assert_logic(malicious, expected)
|
||||
|
||||
def test_cypher_injection_curly_braces_preserved(self):
|
||||
"""Curly brace injection is harmless when enclosed in backticks, so preserved."""
|
||||
malicious = "test}) RETURN 1 //"
|
||||
self.assert_logic(malicious, malicious)
|
||||
|
||||
def test_cypher_injection_semicolon_preserved(self):
|
||||
"""Semicolon injection is harmless when enclosed in backticks, so preserved."""
|
||||
malicious = "test; DROP DATABASE neo4j"
|
||||
self.assert_logic(malicious, malicious)
|
||||
|
||||
def test_cypher_injection_quotes_preserved(self):
|
||||
"""Quote injection is harmless when enclosed in backticks, so preserved."""
|
||||
malicious = 'test" OR 1=1 //'
|
||||
self.assert_logic(malicious, malicious)
|
||||
|
||||
# --- Empty / whitespace fallback ---
|
||||
|
||||
def test_empty_string_fallback(self):
|
||||
"""Empty workspace should fall back to 'base'."""
|
||||
self.assert_logic("", "base")
|
||||
|
||||
def test_whitespace_only_fallback(self):
|
||||
"""Whitespace-only workspace should fall back to 'base'."""
|
||||
self.assert_logic(" ", "base")
|
||||
|
||||
def test_special_chars_only_preserved(self):
|
||||
"""Workspace with only special characters should be preserved."""
|
||||
self.assert_logic("---", "---")
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
def test_leading_trailing_whitespace_stripped(self):
|
||||
"""Leading/trailing whitespace should be stripped before sanitization."""
|
||||
self.assert_logic(" myworkspace ", "myworkspace")
|
||||
|
||||
def test_unicode_characters_preserved(self):
|
||||
"""Non-ASCII/Chinese characters should be preserved."""
|
||||
self.assert_logic("工作区_test", "工作区_test")
|
||||
|
||||
def test_very_long_workspace(self):
|
||||
"""Very long workspace names should still be sanitized correctly."""
|
||||
long_name = "a" * 1000 + "`"
|
||||
expected = "a" * 1000 + "``"
|
||||
self.assert_logic(long_name, expected)
|
||||
|
||||
def test_single_underscore(self):
|
||||
"""Single underscore should be valid."""
|
||||
self.assert_logic("_", "_")
|
||||
|
||||
def test_result_always_escapes_backticks(self):
|
||||
"""Parametric check: any output must not contain unescaped single backticks."""
|
||||
dangerous_inputs = [
|
||||
"normal",
|
||||
"with spaces",
|
||||
"with-dashes",
|
||||
"with.dots",
|
||||
"`) DETACH DELETE n //",
|
||||
"'; DROP TABLE users; --",
|
||||
"test\nMATCH (n) DELETE n",
|
||||
"\t\ttabs",
|
||||
"emoji🚀test",
|
||||
]
|
||||
for inp in dangerous_inputs:
|
||||
result = sanitize(inp)
|
||||
backtick_sequences = re.findall(r"`+", result)
|
||||
for seq in backtick_sequences:
|
||||
# Any sequence of backticks should have an EVEN length because each ` becomes ``
|
||||
assert len(seq) % 2 == 0, (
|
||||
f"Unescaped backtick found in result '{result}' for input '{inp}'"
|
||||
)
|
||||
Reference in New Issue
Block a user