# #region TestAPIKeyRoutes [C:3] [TYPE Module] [SEMANTICS test, api_key, routes, crud] # @BRIEF Contract tests for admin API key CRUD endpoints — list, create (raw key present), revoke, idempotent revoke. # @RELATION BINDS_TO -> [AdminApiKeyRoutes] # @TEST_CONTRACT: GET /api/admin/api-keys -> list of keys without raw_key or key_hash # @TEST_CONTRACT: POST /api/admin/api-keys -> 201 with raw_key in response # @TEST_CONTRACT: DELETE /api/admin/api-keys/{id} -> 200 with status=revoked # @TEST_EDGE: revoked_key_delete -> 404 # @TEST_EDGE: missing_name -> 422 # @TEST_EDGE: missing_permissions -> 422 import pytest import sys from unittest.mock import MagicMock from fastapi.testclient import TestClient # Patch GitService at module level to prevent /app/storage/repositories error _mock_git_svc_cls = MagicMock() _mock_git_svc_cls.return_value = MagicMock() sys.modules['src.services.git._base'] = MagicMock(GitService=_mock_git_svc_cls) sys.modules['src.services.git_service'] = MagicMock(GitService=_mock_git_svc_cls) # ── Fixtures ────────────────────────────────────────────────── @pytest.fixture def client(): """Create a TestClient with all dependencies mocked.""" from src.app import app return TestClient(app) def _mock_admin_auth(): """Apply dependency overrides for admin authentication.""" from src.app import app from src.dependencies import get_current_user mock_role = MagicMock() mock_role.name = "Admin" mock_role.permissions = [] mock_user = MagicMock() mock_user.username = "test-admin" mock_user.roles = [mock_role] async def _mock_get_current_user(): return mock_user app.dependency_overrides[get_current_user] = _mock_get_current_user def _clear_overrides(): from src.app import app app.dependency_overrides = {} @pytest.fixture(autouse=True) def auth_mock(): _mock_admin_auth() yield _clear_overrides() @pytest.fixture def mock_db(): """Patch get_db dependency with in-memory SQLite session.""" 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() 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() # ── Tests ───────────────────────────────────────────────────── class TestListApiKeys: """Contract tests for GET /api/admin/api-keys.""" # #region test_list_empty [C:2] [TYPE Function] # @BRIEF No keys → returns empty list. def test_list_empty(self, client, mock_db): response = client.get("/api/admin/api-keys/") assert response.status_code == 200 data = response.json() assert isinstance(data, list) assert len(data) == 0 # #endregion test_list_empty # #region test_list_with_keys [C:2] [TYPE Function] # @BRIEF Keys exist → returns list without raw_key or key_hash fields. def test_list_with_keys(self, client, mock_db): from src.core.auth.api_key import generate_api_key from src.models.api_key import APIKey # Create a key 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, ) mock_db.add(api_key) mock_db.commit() response = client.get("/api/admin/api-keys/") assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["name"] == "Test Key" assert data[0]["prefix"] == prefix assert "raw_key" not in data[0] assert "key_hash" not in data[0] # #endregion test_list_with_keys class TestCreateApiKey: """Contract tests for POST /api/admin/api-keys.""" # #region test_create_valid [C:2] [TYPE Function] # @BRIEF Valid request → 201 with raw_key, prefix, id. def test_create_valid(self, client, mock_db): response = client.post( "/api/admin/api-keys/", json={ "name": "CI/CD Pipeline", "permissions": ["maintenance:start", "maintenance:end"], "environment_id": "prod-env-1", }, ) assert response.status_code == 201 data = response.json() assert "id" in data assert "raw_key" in data assert data["raw_key"].startswith("ssk_") assert data["prefix"] == data["raw_key"][:11] assert data["name"] == "CI/CD Pipeline" assert data["permissions"] == ["maintenance:start", "maintenance:end"] assert data["active"] is True assert data["environment_id"] == "prod-env-1" # Verify raw key is NOT in DB from src.models.api_key import APIKey db_key = mock_db.query(APIKey).filter(APIKey.id == data["id"]).first() assert db_key is not None assert db_key.key_hash != data["raw_key"] assert db_key.prefix == data["prefix"] # #endregion test_create_valid # #region test_create_missing_name [C:2] [TYPE Function] # @BRIEF Missing name → 400 or 422. def test_create_missing_name(self, client, mock_db): response = client.post( "/api/admin/api-keys/", json={ "permissions": ["maintenance:start"], }, ) assert response.status_code in (400, 422) # #endregion test_create_missing_name # #region test_create_empty_permissions [C:2] [TYPE Function] # @BRIEF Empty permissions → 400 or 422. def test_create_empty_permissions(self, client, mock_db): response = client.post( "/api/admin/api-keys/", json={ "name": "No Perms", "permissions": [], }, ) assert response.status_code in (400, 422) # #endregion test_create_empty_permissions class TestRevokeApiKey: """Contract tests for DELETE /api/admin/api-keys/{id}.""" # #region test_revoke_valid [C:2] [TYPE Function] # @BRIEF Revoke existing active key → 200 with status=revoked. def test_revoke_valid(self, client, mock_db): 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="To Revoke", permissions=["maintenance:start"], active=True, ) mock_db.add(api_key) mock_db.commit() response = client.delete(f"/api/admin/api-keys/{api_key.id}") assert response.status_code == 200 data = response.json() assert data["id"] == api_key.id assert data["status"] == "revoked" # Verify DB mock_db.refresh(api_key) assert api_key.active is False # #endregion test_revoke_valid # #region test_revoke_already_revoked [C:2] [TYPE Function] # @BRIEF Revoke already revoked key → 404. def test_revoke_already_revoked(self, client, mock_db): 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="Already Revoked", permissions=["maintenance:start"], active=False, # already revoked ) mock_db.add(api_key) mock_db.commit() response = client.delete(f"/api/admin/api-keys/{api_key.id}") assert response.status_code == 404 # #endregion test_revoke_already_revoked # #region test_revoke_not_found [C:2] [TYPE Function] # @BRIEF Revoke non-existent key → 404. def test_revoke_not_found(self, client, mock_db): response = client.delete("/api/admin/api-keys/nonexistent-id") assert response.status_code == 404 # #endregion test_revoke_not_found # #endregion TestAPIKeyRoutes