Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |
| async def health(_=Depends(verify_api_key)): | |
| return {"status": "healthy"} | |
| 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, | |
| ) | |
| 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 | |
| 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 |