""" tests/test_api_key_auth.py — TASK-007 / KRIT-001 Verifies API key authentication for all /api/* endpoints. Covers the Definition of Done (3 mandatory scenarios + more): - Request without X-API-Key header -> 401 Unauthorized - Request with wrong API key -> 401 Unauthorized - Request with correct API key -> passes to handler Also covers: - Dependency-level tests (verify_api_key in isolation) - Router-level enforcement on the real analysis.py router - Development bypass when API_KEY is unset """ import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from app.api.dependencies import verify_api_key from app.config.settings import Settings # ───────────────────────────────────────────── # Helpers # ───────────────────────────────────────────── def _make_app_with_auth() -> FastAPI: """Minimal FastAPI app mirroring how analysis.py wires the dependency at router level (router dependencies=[Depends(verify_api_key)]).""" app = FastAPI() @app.get("/api/health") async def health(_=Depends(verify_api_key)): return {"status": "healthy"} @app.post("/api/analyze") async def analyze(_=Depends(verify_api_key)): return {"ok": True} return app # A strong SECRET_KEY for production-mode test scenarios. TASK-006's # SECRET_KEY validator also fires when DEBUG=False, so production-mode # API_KEY tests must pass a valid SECRET_KEY to isolate the API_KEY check. _STRONG_SECRET_FOR_TESTS = ( "t" * 64 # 64 chars, no placeholder markers, passes SECRET_KEY validator ) def _make_settings(api_key: str | None, debug: bool = True) -> Settings: return Settings( _env_file=None, CORS_ORIGINS="http://localhost:3000", ENVIRONMENT="development", DEBUG=debug, # SECRET_KEY required whenever DEBUG=False (TASK-006 validator). SECRET_KEY=_STRONG_SECRET_FOR_TESTS if not debug else None, API_KEY=api_key, ) @pytest.fixture def settings_with_api_key(monkeypatch): """Force get_settings() to return Settings with a known API_KEY.""" test_key = "test-super-secret-api-key-1234567890abcdef" s = _make_settings(api_key=test_key, debug=True) monkeypatch.setattr("app.api.dependencies.get_settings", lambda: s) return s @pytest.fixture def settings_without_api_key(monkeypatch): """Settings with API_KEY=None and DEBUG=True -> dev bypass mode.""" s = _make_settings(api_key=None, debug=True) monkeypatch.setattr("app.api.dependencies.get_settings", lambda: s) return s # ───────────────────────────────────────────── # ★ The three mandatory DoD scenarios # ───────────────────────────────────────────── class TestApiKeyEnforced: def test_no_api_key_header_returns_401(self, settings_with_api_key): """Scenario 1: missing X-API-Key -> 401.""" client = TestClient(_make_app_with_auth()) resp = client.get("/api/health") assert resp.status_code == 401 body = resp.json()["detail"].lower() assert "api key" in body or "x-api-key" in body def test_wrong_api_key_returns_401(self, settings_with_api_key): """Scenario 2: incorrect X-API-Key -> 401.""" client = TestClient(_make_app_with_auth()) resp = client.get( "/api/health", headers={"X-API-Key": "totally-wrong-key"}, ) assert resp.status_code == 401 assert "invalid" in resp.json()["detail"].lower() def test_correct_api_key_passes_through(self, settings_with_api_key): """Scenario 3: correct X-API-Key -> request reaches handler.""" client = TestClient(_make_app_with_auth()) resp = client.get( "/api/health", headers={"X-API-Key": settings_with_api_key.API_KEY}, ) assert resp.status_code == 200 assert resp.json() == {"status": "healthy"} def test_correct_key_works_on_post_too(self, settings_with_api_key): """Auth must protect POST endpoints (e.g. /api/analyze) equally.""" client = TestClient(_make_app_with_auth()) resp = client.post( "/api/analyze", headers={"X-API-Key": settings_with_api_key.API_KEY}, ) assert resp.status_code == 200 def test_empty_string_key_header_returns_401(self, settings_with_api_key): """An empty X-API-Key value must not be treated as 'missing-bypass'.""" client = TestClient(_make_app_with_auth()) resp = client.get("/api/health", headers={"X-API-Key": ""}) assert resp.status_code == 401 # ───────────────────────────────────────────── # Development bypass (convenience) # ───────────────────────────────────────────── class TestDevBypass: def test_no_key_needed_when_api_key_unset_in_dev(self, settings_without_api_key): """When DEBUG=True and API_KEY unset, auth is bypassed for local dev.""" client = TestClient(_make_app_with_auth()) resp = client.get("/api/health") # No header sent, but dev bypass allows it. assert resp.status_code == 200 def test_any_key_ignored_in_dev_bypass(self, settings_without_api_key): """In dev-bypass mode, even a bogus key is accepted (bypass).""" client = TestClient(_make_app_with_auth()) resp = client.get( "/api/health", headers={"X-API-Key": "anything-ignored"}, ) assert resp.status_code == 200 # ───────────────────────────────────────────── # Settings-level validation (API_KEY required in production) # ───────────────────────────────────────────── class TestApiKeySettingsValidation: def test_api_key_field_defaults_none(self): s = _make_settings(api_key=None, debug=True) assert s.API_KEY is None def test_api_key_read_from_value(self): s = _make_settings(api_key="my-key-123", debug=True) assert s.API_KEY == "my-key-123" def test_production_requires_api_key(self): """DEBUG=False without API_KEY must fail at startup.""" with pytest.raises(Exception) as exc_info: _make_settings(api_key=None, debug=False) assert "API_KEY" in str(exc_info.value) def test_production_short_api_key_rejected(self): """Too-short API_KEY must be rejected in production.""" with pytest.raises(Exception) as exc_info: _make_settings(api_key="short", debug=False) assert "short" in str(exc_info.value).lower() def test_production_accepts_strong_api_key(self): """A sufficiently long API_KEY boots fine in production.""" key = "a-valid-production-api-key-1234567890" s = _make_settings(api_key=key, debug=False) assert s.API_KEY == key assert s.DEBUG is False # ───────────────────────────────────────────── # Real router enforcement (integration with analysis.py) # ───────────────────────────────────────────── class TestRealRouterProtected: """Verify the actual analysis.py router carries the dependency, so all 4 endpoints (/analyze, /stocks, /stocks/{symbol}, /health) are protected.""" def test_health_endpoint_requires_key(self, settings_with_api_key): from app.main import app client = TestClient(app) resp = client.get("/api/health") assert resp.status_code == 401 def test_health_endpoint_accepts_valid_key(self, settings_with_api_key): from app.main import app client = TestClient(app) resp = client.get( "/api/health", headers={"X-API-Key": settings_with_api_key.API_KEY}, ) assert resp.status_code == 200 assert resp.json()["status"] == "healthy" def test_stocks_endpoint_requires_key(self, settings_with_api_key): from app.main import app client = TestClient(app) resp = client.get("/api/stocks") assert resp.status_code == 401