UDHOV's picture
Sync from GitHub via hub-sync
76a7925 verified
Raw
History Blame Contribute Delete
12.7 kB
"""
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"
@pytest.fixture(autouse=True)
def _reset_settings() -> None:
"""Reset settings before each test."""
settings.api_key = "test-key" # pragma: allowlist secret
settings.require_api_key = True
@pytest.fixture
def client() -> TestClient:
"""Create test client."""
return TestClient(app)
@pytest.fixture
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"]
@pytest.mark.unit
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"