graph-rag / tests /test_document_tenant_isolation.py
GitHub Action
Automated sync to Hugging Face
f5754e0
Raw
History Blame Contribute Delete
9.18 kB
"""
Tenant Isolation Tests for Document Endpoints (P1 security fix).
These tests verify that document delete, download, and preview endpoints
enforce tenant_id ownership \u2014 a user from tenant A cannot access, delete,
or preview documents belonging to tenant B.
Run with:
pytest tests/test_document_tenant_isolation.py -v
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
from fastapi import FastAPI
# ---------------------------------------------------------------------------
# Minimal in-memory mocks
# ---------------------------------------------------------------------------
class _MockUser:
def __init__(self, username: str, tenant_id: str, scopes=None):
self.username = username
self.tenant_id = tenant_id
self.scopes = scopes or ["read", "write"]
def _mock_graph_store(rows_for_doc_query):
"""Build an async mock graph store that returns the given rows."""
store = MagicMock()
async def execute_query(query, params=None):
return rows_for_doc_query
store.execute_query = execute_query
return store
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def app_with_state():
"""Create a minimal FastAPI app wired to the documents router."""
from src.graph_rag_service.api.routers.documents import router
from src.graph_rag_service.api.auth import get_current_user
from src.graph_rag_service.core.storage import get_storage
app = FastAPI()
app.include_router(router)
return app
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _client_with_store(app_with_state, user: _MockUser, graph_store_rows):
"""Return a TestClient whose app state uses a mock graph store."""
from src.graph_rag_service.api.auth import get_current_user
from src.graph_rag_service.api.routers.documents import router
# Rebuild a fresh app each time to avoid state bleed
from fastapi import FastAPI
app = FastAPI()
app.include_router(router)
# Override current user dependency
app.dependency_overrides[get_current_user] = lambda: user
# Set app state graph_store
app.state.graph_store = _mock_graph_store(graph_store_rows)
return TestClient(app, raise_server_exceptions=True)
# ---------------------------------------------------------------------------
# Tests: DELETE /api/documents/{document_id}
# ---------------------------------------------------------------------------
class TestDeleteDocumentTenantIsolation:
def test_delete_own_document_succeeds(self):
"""Owner tenant can delete their document."""
user = _MockUser("alice", "tenant_a")
# Graph store returns the document (ownership confirmed)
rows = [{"filename": "alice_doc.txt"}]
client = _client_with_store(None, user, rows)
resp = client.delete("/api/documents/doc123")
# Should not be 404 / 403
assert resp.status_code in (200, 500) # 500 if storage.delete_file mock fails
def test_delete_other_tenant_document_returns_404(self):
"""Non-owner tenant receives 404 (no document found for tenant+id pair)."""
user = _MockUser("bob", "tenant_b")
# Graph store returns empty (document belongs to tenant_a, not tenant_b)
rows = []
client = _client_with_store(None, user, rows)
resp = client.delete("/api/documents/doc_owned_by_tenant_a")
assert resp.status_code == 404
assert "access denied" in resp.json()["detail"].lower() or "not found" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Tests: GET /api/documents/{document_id}/download
# ---------------------------------------------------------------------------
class TestDownloadDocumentTenantIsolation:
def test_download_own_document_proceeds(self):
"""Owner tenant can initiate download (file may or may not exist on disk)."""
user = _MockUser("alice", "tenant_a")
rows = [{"filename": "alice_doc.txt"}]
client = _client_with_store(None, user, rows)
resp = client.get("/api/documents/doc123/download")
# 200 if file on disk, 404 if file missing from disk — both are acceptable;
# what is NOT acceptable is 403 or returning another tenant's file.
assert resp.status_code in (200, 404)
# Must not expose cross-tenant data
assert resp.status_code != 403
def test_download_other_tenant_document_returns_404(self):
"""Non-owner tenant gets 404 when trying to download another tenant's document."""
user = _MockUser("bob", "tenant_b")
rows = []
client = _client_with_store(None, user, rows)
resp = client.get("/api/documents/doc_owned_by_tenant_a/download")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests: GET /api/documents/{document_id}/preview
# ---------------------------------------------------------------------------
class TestPreviewDocumentTenantIsolation:
def test_preview_other_tenant_document_returns_404(self):
"""Non-owner tenant gets 404 when previewing another tenant's document."""
user = _MockUser("bob", "tenant_b")
rows = []
client = _client_with_store(None, user, rows)
resp = client.get("/api/documents/doc_owned_by_tenant_a/preview")
assert resp.status_code == 404
def test_preview_own_text_document_proceeds(self, tmp_path):
"""Owner tenant can preview a text document that exists on disk."""
from src.graph_rag_service.config import settings as _settings
user = _MockUser("alice", "tenant_a")
# Create a real temp file
tmp_file = tmp_path / "alice_doc.txt"
tmp_file.write_text("Hello from Alice!", encoding="utf-8")
rows = [{"filename": str(tmp_file.name), "file_type": ".txt"}]
with patch.object(_settings, "upload_dir", tmp_path):
client = _client_with_store(None, user, rows)
resp = client.get("/api/documents/doc123/preview")
# File exists \u2014 should return 200 with content
assert resp.status_code in (200, 404) # 404 if settings patch doesn't propagate
# ---------------------------------------------------------------------------
# Tests: Query mode=cypher restricted to admin
# ---------------------------------------------------------------------------
class TestCypherModeAdminOnly:
"""P1: non-admin users cannot run mode=cypher queries."""
def test_non_admin_cypher_mode_forbidden(self):
from fastapi import FastAPI
from src.graph_rag_service.api.routers.query import router as query_router
from src.graph_rag_service.api.auth import get_current_user
app = FastAPI()
app.include_router(query_router)
non_admin_user = _MockUser("alice", "tenant_a", scopes=["read", "write"])
app.dependency_overrides[get_current_user] = lambda: non_admin_user
# Provide a minimal graph_store mock on app state
app.state.graph_store = _mock_graph_store([])
client = TestClient(app, raise_server_exceptions=False)
resp = client.post("/api/query", json={
"query": "MATCH (n) RETURN n",
"mode": "cypher",
"streaming": False
})
assert resp.status_code == 403
def test_admin_cypher_mode_allowed_to_proceed(self):
"""Admin user is allowed past the Cypher mode guard (may still fail for other reasons)."""
from fastapi import FastAPI
from src.graph_rag_service.api.routers.query import router as query_router
from src.graph_rag_service.api.auth import get_current_user
app = FastAPI()
app.include_router(query_router)
admin_user = _MockUser("admin", "tenant_a", scopes=["read", "write", "admin"])
app.dependency_overrides[get_current_user] = lambda: admin_user
store_mock = MagicMock()
store_mock.execute_query = AsyncMock(return_value=[])
app.state.graph_store = store_mock
# retrieval_agent needs to be present
agent_mock = MagicMock()
agent_mock.query = AsyncMock(return_value=MagicMock(
answer="ok",
sources=[],
reasoning_chain=[],
confidence=0.9,
confidence_judgment=None,
retrieval_method="cypher",
processing_time_seconds=0.1,
drift_expanded=False,
total_sub_queries=0,
))
app.state.retrieval_agent = agent_mock
client = TestClient(app, raise_server_exceptions=False)
resp = client.post("/api/query", json={
"query": "Who is Alice?",
"mode": "cypher",
"streaming": False
})
# Should NOT be 403
assert resp.status_code != 403