""" 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