sahamsense-api / tests /test_api_key_auth.py
Immanuel Partogi Pardede
clean deploy: SahamSense API
d734ab2
Raw
History Blame Contribute Delete
8.83 kB
"""
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