Spaces:
Running
Running
| """ | |
| Integration tests for Production RAG API. | |
| Phase 8.1: API deployment verification. | |
| Phase 8.3: User-provided LLM key flow. | |
| Auth is via HttpOnly session cookie (set by /api/v1/session/init). | |
| """ | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from src.api.main import app | |
| from src.api.middleware.session import create_session_token | |
| from src.api.models.models import settings | |
| TEST_TENANT_ID = "test-tenant-001" | |
| def _reset_settings() -> None: | |
| """Reset settings before each test.""" | |
| settings.api_key = "test-key" # pragma: allowlist secret | |
| settings.require_api_key = True | |
| def client() -> TestClient: | |
| """Create test client.""" | |
| return TestClient(app) | |
| def auth_cookie() -> dict[str, str]: | |
| """Return a valid session cookie.""" | |
| token = create_session_token(TEST_TENANT_ID) | |
| return {"session": token} | |
| class TestHealthEndpoints: | |
| """Test health check endpoints (no auth required).""" | |
| def test_root_endpoint(self, client: TestClient) -> None: | |
| """Test root endpoint returns API info.""" | |
| response = client.get("/") | |
| assert response.status_code == 200 | |
| data = response.json() | |
| assert "app" in data | |
| assert "status" in data | |
| assert data["status"] == "running" | |
| def test_health_endpoint(self, client: TestClient) -> None: | |
| """Test health endpoint returns status.""" | |
| response = client.get("/api/v1/health") | |
| assert response.status_code == 200 | |
| def test_liveness_endpoint(self, client: TestClient) -> None: | |
| """Test liveness endpoint.""" | |
| response = client.get("/api/v1/health/live") | |
| assert response.status_code == 200 | |
| def test_readiness_endpoint(self, client: TestClient) -> None: | |
| """Test readiness endpoint.""" | |
| response = client.get("/api/v1/health/ready") | |
| assert response.status_code == 200 | |
| class TestAuthAndSecurity: | |
| """Test authentication and rate limiting.""" | |
| def test_missing_session(self, client: TestClient) -> None: | |
| """Test request fails without session cookie.""" | |
| response = client.post("/api/v1/query", json={"query": "test"}) | |
| assert response.status_code == 401 | |
| def test_invalid_session(self, client: TestClient) -> None: | |
| """Test request fails with invalid session cookie.""" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={"query": "test"}, | |
| cookies={"session": "invalid-token"}, | |
| ) | |
| assert response.status_code == 401 | |
| def test_expired_session(self, client: TestClient) -> None: | |
| """Test request fails with expired session token.""" | |
| import time as time_module | |
| from src.api.middleware.session import _SESSION_SECRET | |
| secret = _SESSION_SECRET | |
| if secret is None: | |
| from src.api.middleware.session import _get_secret | |
| secret = _get_secret() | |
| import base64 | |
| import hmac | |
| import json | |
| payload = {"t": TEST_TENANT_ID, "e": int(time_module.time()) - 1} | |
| payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") | |
| sig = hmac.new(secret.encode(), payload_b64.encode(), "sha256").hexdigest()[:16] | |
| expired_token = f"{payload_b64}.{sig}" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={"query": "test"}, | |
| cookies={"session": expired_token}, | |
| ) | |
| assert response.status_code == 401 | |
| class TestSessionInit: | |
| """Test session initialization.""" | |
| def test_session_init_returns_tenant_id(self, client: TestClient) -> None: | |
| """Test session init returns tenant_id and sets cookie.""" | |
| response = client.post("/api/v1/session/init") | |
| assert response.status_code == 200 | |
| data = response.json() | |
| assert "tenant_id" in data | |
| assert "api_key" not in data | |
| # Verify cookie is set | |
| set_cookie = response.headers.get("set-cookie", "") | |
| assert "session=" in set_cookie | |
| assert "httponly" in set_cookie.lower() | |
| def test_session_init_restores_existing_tenant(self, client: TestClient) -> None: | |
| """Test session init with a valid tenant_id re-issues cookie for that tenant.""" | |
| # First, create a tenant | |
| create_resp = client.post("/api/v1/session/init") | |
| assert create_resp.status_code == 200 | |
| original_tenant_id = create_resp.json()["tenant_id"] | |
| original_cookie = create_resp.cookies.get("session") | |
| assert original_cookie is not None | |
| assert original_tenant_id is not None | |
| # Now call init again with the same tenant_id — should return same tenant | |
| restore_resp = client.post( | |
| "/api/v1/session/init", | |
| json={"tenant_id": original_tenant_id}, | |
| ) | |
| assert restore_resp.status_code == 200 | |
| restored_data = restore_resp.json() | |
| assert restored_data["tenant_id"] == original_tenant_id | |
| restored_cookie = restore_resp.cookies.get("session") | |
| # Cookie should be different (newly signed) but tenant_id same | |
| assert restored_cookie is not None | |
| def test_session_init_unknown_tenant_creates_new(self, client: TestClient) -> None: | |
| """Test session init with a non-existent tenant_id creates a new tenant.""" | |
| response = client.post( | |
| "/api/v1/session/init", | |
| json={"tenant_id": "tnt_nonexistent"}, | |
| ) | |
| assert response.status_code == 200 | |
| data = response.json() | |
| assert data["tenant_id"] != "tnt_nonexistent" | |
| assert "tenant_id" in data | |
| class TestQueryEndpoints: | |
| """Test query endpoints with auth.""" | |
| def test_query_endpoint_structure(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test full query endpoint returns expected structure.""" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={"query": "test query", "stream": False}, | |
| cookies=auth_cookie, | |
| ) | |
| # 500 is acceptable here if storage is not running, | |
| # but 401 is NOT. | |
| assert response.status_code != 401 | |
| def test_query_empty_query(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test query with empty query returns validation error.""" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={"query": ""}, | |
| cookies=auth_cookie, | |
| ) | |
| # Empty query should fail validation (422) | |
| assert response.status_code == 422 | |
| def test_query_with_llm_api_key_field(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test query accepts llm_api_key field (even if key is invalid).""" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={ | |
| "query": "What is the capital of France?", | |
| "llm_api_key": "sk-test-key-for-structure-verification", # pragma: allowlist secret | |
| }, | |
| cookies=auth_cookie, | |
| ) | |
| # Should not be 401 (auth passes) or 422 (schema valid) | |
| # May be 500 (no actual LLM) but not auth/schema error | |
| assert response.status_code not in (401, 422) | |
| def test_query_llm_api_key_too_short(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test query rejects llm_api_key that is too short.""" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={ | |
| "query": "test", | |
| "llm_api_key": "short", # pragma: allowlist secret | |
| }, | |
| cookies=auth_cookie, | |
| ) | |
| assert response.status_code == 422 | |
| def test_query_llm_api_key_whitespace_stripped(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test query accepts llm_api_key that would fail if not stripped.""" | |
| response = client.post( | |
| "/api/v1/query", | |
| json={ | |
| "query": "test", | |
| "llm_api_key": "sk-or-v1-valid-length-key-for-test", # pragma: allowlist secret | |
| }, | |
| cookies=auth_cookie, | |
| ) | |
| assert response.status_code != 422 | |
| class TestMetadataEndpoints: | |
| """Test metadata endpoints with auth.""" | |
| def test_stats_endpoint(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test stats endpoint returns counts.""" | |
| response = client.get("/api/v1/metadata/stats", cookies=auth_cookie) | |
| assert response.status_code != 401 | |
| def test_departments_endpoint(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test departments endpoint returns list.""" | |
| response = client.get("/api/v1/metadata/departments", cookies=auth_cookie) | |
| assert response.status_code != 401 | |
| class TestIngestEndpoints: | |
| """Test ingest endpoints with auth.""" | |
| def test_ingest_endpoint_structure(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test ingest endpoint accepts request.""" | |
| response = client.post( | |
| "/api/v1/ingest", | |
| json={"file_path": "test.pdf"}, | |
| cookies=auth_cookie, | |
| ) | |
| assert response.status_code != 401 | |
| def test_ingest_file_no_auth(self, client: TestClient) -> None: | |
| """Test file upload without session cookie is rejected.""" | |
| response = client.post( | |
| "/api/v1/ingest/file", | |
| files={"file": ("test.pdf", b"fake content", "application/pdf")}, | |
| ) | |
| assert response.status_code == 401 | |
| def test_ingest_file_valid_cookie(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test file upload with valid session cookie.""" | |
| response = client.post( | |
| "/api/v1/ingest/file", | |
| files={"file": ("test.pdf", b"fake content", "application/pdf")}, | |
| cookies=auth_cookie, | |
| ) | |
| # Auth passes; will fail at pipeline (no storage) → not 401 | |
| assert response.status_code != 401 | |
| def test_ingest_file_unsupported_type(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test file upload with unsupported file type returns 400.""" | |
| response = client.post( | |
| "/api/v1/ingest/file", | |
| files={"file": ("test.exe", b"fake content", "application/octet-stream")}, | |
| cookies=auth_cookie, | |
| ) | |
| assert response.status_code == 400 | |
| data = response.json() | |
| assert "detail" in data | |
| assert "Unsupported file type" in data["detail"] | |
| def test_ingest_file_too_large(self, client: TestClient, auth_cookie: dict) -> None: | |
| """Test file upload with oversized file returns 400.""" | |
| large_content = b"x" * (101 * 1024 * 1024) | |
| response = client.post( | |
| "/api/v1/ingest/file", | |
| files={"file": ("large.pdf", large_content, "application/pdf")}, | |
| cookies=auth_cookie, | |
| ) | |
| assert response.status_code == 400 | |
| data = response.json() | |
| assert "detail" in data | |
| assert "File too large" in data["detail"] | |
| def test_get_ingestion_pipeline_lazy_load_error(self) -> None: | |
| """Test that lazy-load raises HTTPException on import failure (simulated).""" | |
| from src.api.routes.ingest import get_ingestion_pipeline | |
| pipeline = get_ingestion_pipeline() | |
| assert pipeline is not None | |
| class TestCORS: | |
| """Test CORS configuration.""" | |
| def test_app_has_cors_in_title(self, client: TestClient) -> None: | |
| """Test app is properly configured.""" | |
| from src.api.main import app | |
| assert app.title == "Production RAG API" | |
| assert app.version == "1.0.0" | |
| def test_cors_preflight_allowed(self, client: TestClient) -> None: | |
| """Test CORS preflight (OPTIONS) request is allowed.""" | |
| response = client.options( | |
| "/api/v1/ingest/file", | |
| headers={ | |
| "Origin": "http://localhost:3000", | |
| "Access-Control-Request-Method": "POST", | |
| }, | |
| ) | |
| assert response.status_code == 200 | |
| assert response.headers.get("access-control-allow-origin") == "http://localhost:3000" | |
| assert response.headers.get("access-control-allow-methods") is not None | |
| def test_cors_preflight_rejected_origin(self, client: TestClient) -> None: | |
| """Test CORS preflight from unknown origin is rejected.""" | |
| response = client.options( | |
| "/api/v1/ingest/file", | |
| headers={ | |
| "Origin": "https://evil.com", | |
| "Access-Control-Request-Method": "POST", | |
| }, | |
| ) | |
| cors_origin = response.headers.get("access-control-allow-origin") | |
| assert cors_origin is None or cors_origin != "https://evil.com" | |