| """ |
| 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 |
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| @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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| from fastapi import FastAPI |
| app = FastAPI() |
| app.include_router(router) |
|
|
| |
| app.dependency_overrides[get_current_user] = lambda: user |
| |
| app.state.graph_store = _mock_graph_store(graph_store_rows) |
|
|
| return TestClient(app, raise_server_exceptions=True) |
|
|
|
|
| |
| |
| |
|
|
| class TestDeleteDocumentTenantIsolation: |
|
|
| def test_delete_own_document_succeeds(self): |
| """Owner tenant can delete their document.""" |
| user = _MockUser("alice", "tenant_a") |
| |
| rows = [{"filename": "alice_doc.txt"}] |
| client = _client_with_store(None, user, rows) |
| resp = client.delete("/api/documents/doc123") |
| |
| assert resp.status_code in (200, 500) |
|
|
| 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") |
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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") |
| |
| |
| assert resp.status_code in (200, 404) |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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") |
| |
| 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") |
| |
| assert resp.status_code in (200, 404) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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 |
| |
| 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 |
| }) |
| |
| assert resp.status_code != 403 |
|
|