| """ |
| 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() |
|
|
| |
| db._create_vector_index.return_value = None |
|
|
| |
| new_table_record_count = {"count": 0} |
|
|
| |
| async def table_exists_side_effect(db_instance, name): |
| if name.lower() == "lightrag_doc_chunks": |
| return True |
| elif name.lower() == "lightrag_doc_chunks_model_1536d": |
| return False |
| return False |
|
|
| |
| 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, |
| }, |
| ] |
|
|
| |
| async def query_side_effect(sql, params, **kwargs): |
| multirows = kwargs.get("multirows", False) |
| sql_upper = sql.upper() |
|
|
| |
| if ( |
| "COUNT(*)" in sql_upper |
| and "MODEL_1536D" in sql_upper |
| and "WHERE WORKSPACE" in sql_upper |
| ): |
| return new_table_record_count |
|
|
| |
| elif "COUNT(*)" in sql_upper and "WHERE WORKSPACE" in sql_upper: |
| if params and params[0] == "workspace_a": |
| return {"count": 2} |
| elif params and params[0] == "workspace_b": |
| return {"count": 3} |
| return {"count": 0} |
|
|
| |
| elif ( |
| "COUNT(*)" in sql_upper |
| and "LIGHTRAG" in sql_upper |
| and "WHERE WORKSPACE" not in sql_upper |
| ): |
| return {"count": 5} |
|
|
| |
| elif "SELECT" in sql_upper and "FROM" in sql_upper and multirows: |
| workspace = params[0] if params else None |
| if workspace == "workspace_a": |
| |
| if "id >" in sql.lower(): |
| |
| last_id = params[1] if len(params) > 1 else None |
| |
| 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: |
| |
| return mock_records_a |
| return [] |
|
|
| return {} |
|
|
| db.query.side_effect = query_side_effect |
| db.execute = AsyncMock() |
|
|
| |
| async def check_table_exists_side_effect(name): |
| if name.lower() == "lightrag_doc_chunks": |
| return True |
| elif name.lower() == "lightrag_doc_chunks_model_1536d": |
| return False |
| return False |
|
|
| db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect) |
|
|
| |
| migration_executed = [] |
|
|
| async def mock_run_with_retry(operation, *args, **kwargs): |
| migration_executed.append(True) |
| new_table_record_count["count"] = 2 |
| return None |
|
|
| db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry) |
|
|
| |
| await PGVectorStorage.setup_table( |
| db, |
| "LIGHTRAG_DOC_CHUNKS_model_1536d", |
| workspace="workspace_a", |
| embedding_dim=1536, |
| legacy_table_name="LIGHTRAG_DOC_CHUNKS", |
| base_table="LIGHTRAG_DOC_CHUNKS", |
| ) |
|
|
| |
| 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() |
|
|
| |
| with pytest.raises(ValueError, match="workspace must be provided"): |
| await PGVectorStorage.setup_table( |
| db, |
| "lightrag_doc_chunks_model_1536d", |
| workspace=None, |
| 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() |
|
|
| |
| db._create_vector_index.return_value = None |
|
|
| |
| queried_workspace = None |
| new_table_count = {"count": 0} |
|
|
| |
| 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": |
| return True |
| elif name.lower() == "lightrag_doc_chunks_model_1536d": |
| return False |
| return False |
|
|
| async def query_side_effect(sql, params, **kwargs): |
| nonlocal queried_workspace |
| multirows = kwargs.get("multirows", False) |
| sql_upper = sql.upper() |
|
|
| |
| if ( |
| "COUNT(*)" in sql_upper |
| and "MODEL_1536D" in sql_upper |
| and "WHERE WORKSPACE" in sql_upper |
| ): |
| return new_table_count |
|
|
| |
| elif "COUNT(*)" in sql_upper and "WHERE WORKSPACE" in sql_upper: |
| queried_workspace = params[0] if params else None |
| return {"count": 1} |
|
|
| |
| elif ( |
| "COUNT(*)" in sql_upper |
| and "LIGHTRAG" in sql_upper |
| and "WHERE WORKSPACE" not in sql_upper |
| ): |
| return {"count": 3} |
|
|
| |
| elif "SELECT" in sql_upper and "FROM" in sql_upper and multirows: |
| workspace = params[0] if params else None |
| if workspace == "workspace_b": |
| |
| if "id >" in sql.lower(): |
| |
| last_id = params[1] if len(params) > 1 else None |
| |
| 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: |
| |
| return mock_records_b |
| return [] |
|
|
| return {} |
|
|
| db.query.side_effect = query_side_effect |
| db.execute = AsyncMock() |
|
|
| |
| async def check_table_exists_side_effect(name): |
| if name.lower() == "lightrag_doc_chunks": |
| return True |
| elif name.lower() == "lightrag_doc_chunks_model_1536d": |
| return False |
| return False |
|
|
| db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect) |
|
|
| |
| migration_executed = [] |
|
|
| async def mock_run_with_retry(operation, *args, **kwargs): |
| migration_executed.append(True) |
| new_table_count["count"] = 1 |
| return None |
|
|
| db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry) |
|
|
| |
| await PGVectorStorage.setup_table( |
| db, |
| "LIGHTRAG_DOC_CHUNKS_model_1536d", |
| workspace="workspace_b", |
| embedding_dim=1536, |
| legacy_table_name="LIGHTRAG_DOC_CHUNKS", |
| base_table="LIGHTRAG_DOC_CHUNKS", |
| ) |
|
|
| |
| assert queried_workspace == "workspace_b", "Should only query workspace_b" |
|
|