# #region TestAPIKeyAuth [C:3] [TYPE Module] [SEMANTICS test, api_key, auth, dependency] # @BRIEF Contract tests for API key authentication — valid key, invalid, revoked, expired, missing permission, # environment scoping, JWT precedence. Uses TestClient with dependency overrides. # @RELATION BINDS_TO -> [APIKeyUtilities] # @RELATION BINDS_TO -> [APIKeyModel] # @RELATION BINDS_TO -> [MaintenanceRoutesModule] # @TEST_CONTRACT: get_api_key_principal returns APIKeyPrincipal for valid key, None for no header, 401 for invalid/revoked/expired. # @TEST_CONTRACT: require_api_key_or_jwt rejects invalid/revoked/expired keys, checks permissions, enforces environment scope. # @TEST_EDGE: missing_header -> returns None # @TEST_EDGE: invalid_key -> 401 # @TEST_EDGE: revoked_key -> 401 # @TEST_EDGE: expired_key -> 401 # @TEST_EDGE: missing_permission -> 403 # @TEST_EDGE: environment_scope_mismatch -> 400 # @TEST_EDGE: jwt_precedence -> JWT takes precedence over API key from datetime import UTC, datetime, timedelta import pytest from unittest.mock import AsyncMock, MagicMock from fastapi import HTTPException from fastapi.testclient import TestClient # ── Fixtures ────────────────────────────────────────────────── @pytest.fixture def db_session(): """Create an in-memory SQLite session with the api_keys table.""" from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from src.models.mapping import Base engine = create_engine( "sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() yield session session.close() @pytest.fixture def valid_api_key(db_session): """Create a valid API key and return its raw value, prefix, and database row.""" from src.core.auth.api_key import generate_api_key from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Test Key", permissions=["maintenance:start", "maintenance:end"], active=True, ) db_session.add(api_key) db_session.commit() return raw, api_key @pytest.fixture def client(): """Create a TestClient.""" from src.app import app return TestClient(app) @pytest.fixture def mock_db(): """Patch get_db dependency with in-memory SQLite session (same pattern as test_maintenance_api).""" from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from src.app import app from src.dependencies import get_db from src.models.mapping import Base engine = create_engine( "sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() # Add default maintenance settings (needed by /api/maintenance endpoints) from src.models.maintenance import DashboardScope, MaintenanceSettings settings = MaintenanceSettings( id="default", target_environment_id="test-env", display_timezone="UTC", banner_template="Test: {message} ({start_time}-{end_time})", dashboard_scope=DashboardScope.PUBLISHED_ONLY, excluded_dashboard_ids=[], forced_dashboard_ids=[], ) session.add(settings) session.commit() def _get_db_override(): db = Session() try: yield db finally: db.close() app.dependency_overrides[get_db] = _get_db_override yield session app.dependency_overrides.pop(get_db, None) session.close() @pytest.fixture def mock_task_manager(): """Patch get_task_manager to return a mock.""" from src.app import app from src.dependencies import get_task_manager mock_tm = MagicMock() mock_task = MagicMock() mock_task.id = "test-task-id-123" mock_tm.create_task = AsyncMock(return_value=mock_task) app.dependency_overrides[get_task_manager] = lambda: mock_tm yield mock_tm app.dependency_overrides.pop(get_task_manager, None) # ── Tests for get_api_key_principal ─────────────────────────── class TestGetApiKeyPrincipal: """Contract tests for get_api_key_principal dependency.""" # #region test_no_header_returns_none [C:2] [TYPE Function] # @BRIEF No X-API-Key header → returns None. @pytest.mark.asyncio async def test_no_header_returns_none(self): from fastapi import Request from src.dependencies import get_api_key_principal # Mock a request without the header mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = None mock_db = MagicMock() result = await get_api_key_principal(mock_request, mock_db) assert result is None # #endregion test_no_header_returns_none # #region test_valid_key_returns_principal [C:2] [TYPE Function] # @BRIEF Valid API key → returns APIKeyPrincipal with correct fields. @pytest.mark.asyncio async def test_valid_key_returns_principal(self, db_session, valid_api_key): from fastapi import Request from src.dependencies import get_api_key_principal raw_key, api_key_row = valid_api_key mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = raw_key result = await get_api_key_principal(mock_request, db_session) assert result is not None assert result.name == "Test Key" assert result.api_key_id == api_key_row.id assert result.environment_id is None assert "maintenance:start" in result.permissions # #endregion test_valid_key_returns_principal # #region test_invalid_key_raises_401 [C:2] [TYPE Function] # @BRIEF Invalid API key → 401. @pytest.mark.asyncio async def test_invalid_key_raises_401(self, db_session): from fastapi import Request from src.dependencies import get_api_key_principal mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = "ssk_invalid_key_that_does_not_exist" with pytest.raises(HTTPException) as exc: await get_api_key_principal(mock_request, db_session) assert exc.value.status_code == 401 # #endregion test_invalid_key_raises_401 # #region test_revoked_key_raises_401 [C:2] [TYPE Function] # @BRIEF Revoked (active=False) API key → 401. @pytest.mark.asyncio async def test_revoked_key_raises_401(self, db_session): from fastapi import Request from src.core.auth.api_key import generate_api_key from src.dependencies import get_api_key_principal from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Revoked Key", permissions=["maintenance:start"], active=False, # revoked ) db_session.add(api_key) db_session.commit() mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = raw with pytest.raises(HTTPException) as exc: await get_api_key_principal(mock_request, db_session) assert exc.value.status_code == 401 # #endregion test_revoked_key_raises_401 # #region test_expired_key_raises_401 [C:2] [TYPE Function] # @BRIEF Expired API key → 401. @pytest.mark.asyncio async def test_expired_key_raises_401(self, db_session): from fastapi import Request from src.core.auth.api_key import generate_api_key from src.dependencies import get_api_key_principal from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Expired Key", permissions=["maintenance:start"], active=True, expires_at=datetime.now(UTC) - timedelta(hours=1), # expired ) db_session.add(api_key) db_session.commit() mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = raw with pytest.raises(HTTPException) as exc: await get_api_key_principal(mock_request, db_session) assert exc.value.status_code == 401 # #endregion test_expired_key_raises_401 # #endregion TestAPIKeyAuth # ── Tests for require_api_key_or_jwt (integration via TestClient) ────── class TestRequireApiKeyOrJwt: """Contract tests for require_api_key_or_jwt dependency factory via TestClient.""" API_START_URL = "/api/maintenance/start" START_PAYLOAD = { "tables": ["raw.sales"], "start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), "end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(), "message": "Test maintenance", "environment_id": "ss-dev", } @staticmethod def _create_api_key(db, environment_id=None, permissions=None): """Helper to create an API key in the test DB and return the raw key.""" from src.core.auth.api_key import generate_api_key from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Test Key", permissions=permissions or ["maintenance:start"], active=True, environment_id=environment_id, ) db.add(api_key) db.commit() return raw # #region test_api_key_auth_valid [C:2] [TYPE Function] # @BRIEF Valid API key with matching permission → 202. def test_api_key_auth_valid(self, client, mock_db, mock_task_manager): raw_key = self._create_api_key(mock_db, permissions=["maintenance:start"]) response = client.post( self.API_START_URL, json=self.START_PAYLOAD, headers={"X-API-Key": raw_key}, ) assert response.status_code == 202 data = response.json() assert "task_id" in data assert data["status"] == "pending" # #endregion test_api_key_auth_valid # #region test_api_key_auth_wrong_permission [C:2] [TYPE Function] # @BRIEF API key lacks required permission → 403. def test_api_key_auth_wrong_permission(self, client, mock_db, mock_task_manager): # Key has 'maintenance:end' only, not 'maintenance:start' raw_key = self._create_api_key(mock_db, permissions=["maintenance:end"]) response = client.post( self.API_START_URL, json=self.START_PAYLOAD, headers={"X-API-Key": raw_key}, ) assert response.status_code == 403 data = response.json() assert "detail" in data # #endregion test_api_key_auth_wrong_permission # #region test_api_key_auth_revoked [C:2] [TYPE Function] # @BRIEF Revoked API key → 401. def test_api_key_auth_revoked(self, client, mock_db, mock_task_manager): from src.core.auth.api_key import generate_api_key from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Revoked Key", permissions=["maintenance:start"], active=False, ) mock_db.add(api_key) mock_db.commit() response = client.post( self.API_START_URL, json=self.START_PAYLOAD, headers={"X-API-Key": raw}, ) assert response.status_code == 401 assert "revoked" in response.json().get("detail", "").lower() # #endregion test_api_key_auth_revoked # #region test_api_key_auth_expired [C:2] [TYPE Function] # @BRIEF Expired API key → 401. def test_api_key_auth_expired(self, client, mock_db, mock_task_manager): from src.core.auth.api_key import generate_api_key from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Expired Key", permissions=["maintenance:start"], active=True, expires_at=datetime.now(UTC) - timedelta(hours=1), ) mock_db.add(api_key) mock_db.commit() response = client.post( self.API_START_URL, json=self.START_PAYLOAD, headers={"X-API-Key": raw}, ) assert response.status_code == 401 assert "expired" in response.json().get("detail", "").lower() # #endregion test_api_key_auth_expired # #region test_api_key_auth_invalid [C:2] [TYPE Function] # @BRIEF Non-existent key → 401. def test_api_key_auth_invalid(self, client, mock_db, mock_task_manager): response = client.post( self.API_START_URL, json=self.START_PAYLOAD, headers={"X-API-Key": "ssk_invalid_nonexistent_key"}, ) assert response.status_code == 401 assert "invalid" in response.json().get("detail", "").lower() # #endregion test_api_key_auth_invalid # #region test_api_key_auth_environment_scope [C:2] [TYPE Function] # @BRIEF Key scoped to ss-dev, request for ss-prod → 400. def test_api_key_auth_environment_scope(self, client, mock_db, mock_task_manager): raw_key = self._create_api_key( mock_db, environment_id="ss-dev", permissions=["maintenance:start"], ) payload = dict(self.START_PAYLOAD) payload["environment_id"] = "ss-prod" # Mismatch! response = client.post( self.API_START_URL, json=payload, headers={"X-API-Key": raw_key}, ) assert response.status_code == 400 data = response.json() assert "restricted" in data.get("detail", "").lower() assert "ss-dev" in data.get("detail", "") # #endregion test_api_key_auth_environment_scope # #region test_api_key_auth_environment_scope_ok [C:2] [TYPE Function] # @BRIEF Key scoped to ss-dev, request for ss-dev → 202. def test_api_key_auth_environment_scope_ok(self, client, mock_db, mock_task_manager): raw_key = self._create_api_key( mock_db, environment_id="ss-dev", permissions=["maintenance:start"], ) payload = dict(self.START_PAYLOAD) payload["environment_id"] = "ss-dev" # Match! response = client.post( self.API_START_URL, json=payload, headers={"X-API-Key": raw_key}, ) assert response.status_code == 202 data = response.json() assert "task_id" in data # #endregion test_api_key_auth_environment_scope_ok # #region test_jwt_precedence [C:2] [TYPE Function] # @BRIEF Both valid API key + valid JWT → JWT takes precedence. def test_jwt_precedence(self, client, mock_db, mock_task_manager): from src.app import app from src.core.auth.jwt import create_access_token from src.dependencies import get_auth_db from src.models.auth import Role, User raw_key = self._create_api_key( mock_db, environment_id="ss-dev", permissions=["maintenance:start"], ) # Create a JWT user with Admin role (full access) role = Role(name="Admin", description="Admin role") mock_db.add(role) mock_db.commit() user = User( username="jwt-user", password_hash="nohash", auth_source="LOCAL", is_active=True, ) user.roles.append(role) mock_db.add(user) mock_db.commit() # Create a valid JWT token token = create_access_token(data={"sub": "jwt-user"}) # Override get_auth_db to use our mock_db for auth queries too # The auth DB and main DB are separate in production, but for tests # we use the same in-memory DB def _get_auth_db_override(): yield mock_db app.dependency_overrides[get_auth_db] = _get_auth_db_override # Send request with BOTH X-API-Key and Authorization: Bearer response = client.post( self.API_START_URL, json=self.START_PAYLOAD, headers={ "X-API-Key": raw_key, "Authorization": f"Bearer {token}", }, ) assert response.status_code == 202 data = response.json() assert "task_id" in data # Cleanup app.dependency_overrides.pop(get_auth_db, None) # #endregion test_jwt_precedence # #endregion TestRequireApiKeyOrJwt