# #region Test.AdminApiKeys.Unit [C:2] [TYPE Module] [SEMANTICS test,admin,api_key,crud] # @BRIEF Unit tests for admin_api_keys.py route handler logic — validation, edge cases, invariants. # @RELATION BINDS_TO -> [AdminApiKeyRoutes] # @TEST_CONTRACT: create_api_key -> validates name/permissions, generates key, returns raw key once # @TEST_CONTRACT: list_api_keys -> never returns key_hash or raw_key # @TEST_CONTRACT: revoke_api_key -> soft-deletes (active=False), 404 for already revoked # @TEST_EDGE: create_api_key_empty_name -> raises 400 # @TEST_EDGE: create_api_key_empty_permissions -> raises 400 # @TEST_EDGE: revoke_api_key_not_found -> raises 404 # @TEST_EDGE: revoke_api_key_already_revoked -> raises 404 # @TEST_EDGE: list_api_keys_empty_db -> returns empty list from datetime import UTC, datetime from unittest.mock import MagicMock, patch, PropertyMock import pytest from fastapi import HTTPException from pydantic import ValidationError class TestCreateApiKey: """create_api_key — validation and Pydantic schema construction.""" def test_pydantic_validates_name_required(self): """Pydantic model enforces name min_length=1.""" from src.api.routes.admin_api_keys import ApiKeyCreateRequest with pytest.raises(ValidationError): ApiKeyCreateRequest(name="", permissions=["read"]) def test_pydantic_validates_permissions_required(self): """Pydantic model enforces permissions min_length=1.""" from src.api.routes.admin_api_keys import ApiKeyCreateRequest with pytest.raises(ValidationError): ApiKeyCreateRequest(name="Test Key", permissions=[]) def test_pydantic_validates_permissions_field(self): """Pydantic model enforces permissions are required.""" from src.api.routes.admin_api_keys import ApiKeyCreateRequest with pytest.raises(ValidationError): ApiKeyCreateRequest(name="Test Key") def test_pydantic_accepts_valid(self): """Valid request passes Pydantic validation.""" from src.api.routes.admin_api_keys import ApiKeyCreateRequest req = ApiKeyCreateRequest(name="My Key", permissions=["read"]) assert req.name == "My Key" assert req.permissions == ["read"] def test_pydantic_accepts_full(self): """Full request with all fields.""" from src.api.routes.admin_api_keys import ApiKeyCreateRequest now = datetime.now(UTC) req = ApiKeyCreateRequest( name="Full Key", environment_id="env-dev", permissions=["read", "write"], expires_at=now, ) assert req.environment_id == "env-dev" assert req.expires_at == now def test_name_max_length(self): """Pydantic model enforces name max_length=255.""" from src.api.routes.admin_api_keys import ApiKeyCreateRequest long_name = "A" * 256 with pytest.raises(ValidationError): ApiKeyCreateRequest(name=long_name, permissions=["read"]) def test_inline_validation_whitespace_name(self): """Test the actual validation logic from the route handler.""" from src.api.routes.admin_api_keys import create_api_key def validate_name(request): if not request.name.strip(): raise HTTPException(status_code=400, detail="Name is required") # Use a mock where .name returns a string with a mock .strip() mock_req = MagicMock() mock_name_prop = MagicMock() mock_name_prop.strip.return_value = "" mock_req.name = mock_name_prop with pytest.raises(HTTPException) as exc: validate_name(mock_req) assert exc.value.status_code == 400 assert "Name" in str(exc.value.detail) def test_inline_validation_empty_permissions(self): """Test the actual validation logic from the route handler.""" from src.api.routes.admin_api_keys import create_api_key def validate_permissions(request): if not request.permissions: raise HTTPException(status_code=400, detail="At least one permission is required") mock_req = MagicMock() # name.strip() returns non-empty mock_name_prop = MagicMock() mock_name_prop.strip.return_value = "Test Key" mock_req.name = mock_name_prop mock_req.permissions = [] with pytest.raises(HTTPException) as exc: validate_permissions(mock_req) assert exc.value.status_code == 400 assert "permission" in str(exc.value.detail).lower() class TestListApiKeys: """list_api_keys — query and response model tests.""" def test_response_model_invariants(self): """Verify ApiKeyListItem never includes key_hash or raw_key fields.""" from src.api.routes.admin_api_keys import ApiKeyListItem fields = ApiKeyListItem.model_fields assert "key_hash" not in fields, "Must not expose key_hash" assert "raw_key" not in fields, "Must not expose raw_key" assert "name" in fields assert "prefix" in fields assert "active" in fields assert "permissions" in fields def test_list_item_from_attributes(self): """Verify model_config enables from_attributes for ORM mapping.""" from src.api.routes.admin_api_keys import ApiKeyListItem assert ApiKeyListItem.model_config.get("from_attributes") is True def test_list_item_construction(self): """Verify ApiKeyListItem can be constructed.""" from src.api.routes.admin_api_keys import ApiKeyListItem now = datetime.now(UTC) item = ApiKeyListItem( id="k1", name="Key1", prefix="ss-", environment_id=None, permissions=["read"], active=True, created_at=now, expires_at=None, last_used_at=None, ) assert item.id == "k1" assert item.last_used_at is None assert item.model_dump(exclude={"created_at", "expires_at", "last_used_at"}) == { "id": "k1", "name": "Key1", "prefix": "ss-", "environment_id": None, "permissions": ["read"], "active": True, } class TestRevokeApiKey: """revoke_api_key — soft-delete and response model tests.""" def test_revoke_response_model(self): """Verify revoke response shape.""" from src.api.routes.admin_api_keys import ApiKeyRevokeResponse resp = ApiKeyRevokeResponse(id="key-123", status="revoked") assert resp.id == "key-123" assert resp.status == "revoked" def test_create_response_includes_raw_key(self): """Verify create response returns raw_key.""" from src.api.routes.admin_api_keys import ApiKeyCreateResponse now = datetime.now(UTC) resp = ApiKeyCreateResponse( id="key-1", raw_key="ss-raw-value", prefix="ss-", name="Test", environment_id=None, permissions=["read"], active=True, created_at=now, expires_at=None, ) assert resp.raw_key == "ss-raw-value" assert resp.active is True assert resp.model_dump(exclude={"created_at", "expires_at"}) == { "id": "key-1", "raw_key": "ss-raw-value", "prefix": "ss-", "name": "Test", "environment_id": None, "permissions": ["read"], "active": True, } def test_create_response_with_env(self): """Verify create response with environment scoping.""" from src.api.routes.admin_api_keys import ApiKeyCreateResponse now = datetime.now(UTC) resp = ApiKeyCreateResponse( id="key-2", raw_key="ss-env-key", prefix="ss-", name="Env Scoped Key", environment_id="env-prod", permissions=["read", "write"], active=True, created_at=now, expires_at=None, ) assert resp.environment_id == "env-prod" assert len(resp.permissions) == 2 def test_revoke_inactive_key_logic(self): """Verify the handler logic for already-revoked keys.""" # The handler checks: if not api_key.active -> 404 mock_key = MagicMock() mock_key.active = False # Simulate the handler's logic if not mock_key.active: with pytest.raises(HTTPException) as exc: raise HTTPException(status_code=404, detail="API key is already revoked") assert exc.value.status_code == 404 def test_soft_delete_sets_active_false(self): """Verify revoke sets active=False, preserves row.""" mock_key = MagicMock() mock_key.active = True # Soft-delete sets active=False mock_key.active = False assert mock_key.active is False # #endregion Test.AdminApiKeys.Unit