diff --git a/src/authentication/k8s.py b/src/authentication/k8s.py index fba8336fe..0f6d320b3 100644 --- a/src/authentication/k8s.py +++ b/src/authentication/k8s.py @@ -1,6 +1,7 @@ """Manage authentication flow for FastAPI endpoints with K8S/OCP.""" import os +from http import HTTPStatus from typing import Optional, Self, cast import kubernetes.client @@ -29,8 +30,45 @@ ) -class ClusterIDUnavailableError(Exception): - """Cluster ID is not available.""" +class K8sAuthenticationError(Exception): + """Base exception for Kubernetes authentication errors.""" + + +class K8sAPIConnectionError(K8sAuthenticationError): + """Cannot connect to Kubernetes API server. + + Indicates transient failures that may be resolved by retrying. + Maps to HTTP 503 Service Unavailable. + """ + + +class K8sConfigurationError(K8sAuthenticationError): + """Kubernetes cluster configuration issue. + + Indicates persistent configuration problems requiring admin intervention. + Maps to HTTP 500 Internal Server Error. + """ + + +class ClusterVersionNotFoundError(K8sConfigurationError): + """ClusterVersion resource not found in OpenShift cluster. + + Raised when the ClusterVersion custom resource does not exist (HTTP 404). + """ + + +class ClusterVersionPermissionError(K8sConfigurationError): + """No permission to access ClusterVersion resource. + + Raised when RBAC denies access to the ClusterVersion resource (HTTP 403). + """ + + +class InvalidClusterVersionError(K8sConfigurationError): + """ClusterVersion resource has invalid structure or missing required fields. + + Raised when the ClusterVersion exists but is missing spec.clusterID or has wrong type. + """ class K8sClientSingleton: @@ -156,8 +194,10 @@ def _get_cluster_id(cls) -> str: str: The cluster's `clusterID`. Raises: - ClusterIDUnavailableError: If the cluster ID cannot be obtained due - to missing keys, an API error, or any unexpected error. + K8sAPIConnectionError: If the Kubernetes API is unreachable or returns 5xx errors. + ClusterVersionNotFoundError: If the ClusterVersion resource does not exist (404). + ClusterVersionPermissionError: If access to ClusterVersion is denied (403). + InvalidClusterVersionError: If ClusterVersion has invalid structure or missing fields. """ try: custom_objects_api = cls.get_custom_objects_api() @@ -170,27 +210,64 @@ def _get_cluster_id(cls) -> str: ) spec = version_data.get("spec") if not isinstance(spec, dict): - raise ClusterIDUnavailableError( + raise InvalidClusterVersionError( "Missing or invalid 'spec' in ClusterVersion" ) cluster_id = spec.get("clusterID") if not isinstance(cluster_id, str) or not cluster_id.strip(): - raise ClusterIDUnavailableError( + raise InvalidClusterVersionError( "Missing or invalid 'clusterID' in ClusterVersion" ) cls._cluster_id = cluster_id return cluster_id - except KeyError as e: + except ApiException as e: + # Handle specific HTTP status codes from Kubernetes API + if e.status is None: + # No status code indicates a connection/network issue + logger.error("Kubernetes API error with no status code: %s", e.reason) + raise K8sAPIConnectionError( + f"Failed to connect to Kubernetes API: {e.reason}" + ) from e + + if e.status == HTTPStatus.NOT_FOUND: + logger.error( + "ClusterVersion resource 'version' not found in cluster: %s", + e.reason, + ) + raise ClusterVersionNotFoundError( + "ClusterVersion 'version' resource not found in OpenShift cluster" + ) from e + if e.status == HTTPStatus.FORBIDDEN: + logger.error( + "Permission denied to access ClusterVersion resource: %s", e.reason + ) + raise ClusterVersionPermissionError( + "Insufficient permissions to read ClusterVersion resource" + ) from e + # Classify errors by status code range + # 5xx errors and 429 (rate limit) are transient - map to 503 + if ( + e.status >= HTTPStatus.INTERNAL_SERVER_ERROR + or e.status == HTTPStatus.TOO_MANY_REQUESTS + ): + logger.error( + "Kubernetes API unavailable while fetching ClusterVersion (status %s): %s", + e.status, + e.reason, + ) + raise K8sAPIConnectionError( + f"Failed to connect to Kubernetes API: {e.reason} (status {e.status})" + ) from e + # All other errors (4xx client errors) are configuration issues - map to 500 logger.error( - "Failed to get cluster_id from cluster, missing keys in version object" + "Kubernetes API returned client error while fetching " + "ClusterVersion (status %s): %s", + e.status, + e.reason, ) - raise ClusterIDUnavailableError("Failed to get cluster ID") from e - except ApiException as e: - logger.error("API exception during ClusterInfo: %s", e) - raise ClusterIDUnavailableError("Failed to get cluster ID") from e - except Exception as e: - logger.error("Unexpected error during getting cluster ID: %s", e) - raise ClusterIDUnavailableError("Failed to get cluster ID") from e + raise K8sConfigurationError( + f"Kubernetes API request failed: {e.reason} (status {e.status})" + ) from e @classmethod def get_cluster_id(cls) -> str: @@ -207,7 +284,10 @@ def get_cluster_id(cls) -> str: str: The cluster identifier. Raises: - ClusterIDUnavailableError: If running in-cluster and fetching the cluster ID fails. + K8sAPIConnectionError: If the Kubernetes API is unreachable. + ClusterVersionNotFoundError: If the ClusterVersion resource does not exist. + ClusterVersionPermissionError: If access to ClusterVersion is denied. + InvalidClusterVersionError: If ClusterVersion has invalid structure. """ if cls._instance is None: cls() @@ -230,7 +310,10 @@ def get_user_info(token: str) -> Optional[kubernetes.client.V1TokenReviewStatus] The V1TokenReviewStatus if the token is valid, None otherwise. Raises: - HTTPException: If unable to connect to Kubernetes API or unexpected error occurs. + HTTPException: + 503 if Kubernetes API is unavailable (5xx errors, 429 rate limit). + 503 if unable to initialize Kubernetes client. + 500 if Kubernetes API configuration issue (4xx errors). """ try: auth_api = K8sClientSingleton.get_authn_api() @@ -254,8 +337,47 @@ def get_user_info(token: str) -> Optional[kubernetes.client.V1TokenReviewStatus] if status is not None and status.authenticated: return status return None + except ApiException as e: + if e.status is None: + logger.error( + "Kubernetes API error during TokenReview with no status code: %s", + e.reason, + ) + response = ServiceUnavailableResponse( + backend_name="Kubernetes API", + cause=f"Failed to connect to Kubernetes API: {e.reason}", + ) + raise HTTPException(**response.model_dump()) from e + + # 5xx errors and 429 (rate limit) are transient - map to 503 + if ( + e.status >= HTTPStatus.INTERNAL_SERVER_ERROR + or e.status == HTTPStatus.TOO_MANY_REQUESTS + ): + logger.error( + "Kubernetes API unavailable during TokenReview (status %s): %s", + e.status, + e.reason, + ) + response = ServiceUnavailableResponse( + backend_name="Kubernetes API", + cause=f"Kubernetes API unavailable: {e.reason} (status {e.status})", + ) + raise HTTPException(**response.model_dump()) from e + + # All other errors (4xx client errors) are configuration issues - map to 500 + logger.error( + "Kubernetes API returned client error during TokenReview (status %s): %s", + e.status, + e.reason, + ) + response_obj = InternalServerErrorResponse( + response="Internal server error", + cause=f"Kubernetes API request failed: {e.reason} (status {e.status})", + ) + raise HTTPException(**response_obj.model_dump()) from e except Exception as e: # pylint: disable=broad-exception-caught - logger.error("API exception during TokenReview: %s", e) + logger.error("Unexpected error during TokenReview: %s", e) return None @@ -325,11 +447,20 @@ async def __call__(self, request: Request) -> tuple[str, str, bool, str]: if user.username == "kube:admin": try: user.uid = K8sClientSingleton.get_cluster_id() - except ClusterIDUnavailableError as e: - logger.error("Failed to get cluster ID: %s", e) + except K8sAPIConnectionError as e: + # Kubernetes API is unreachable - return 503 + logger.error("Cannot connect to Kubernetes API: %s", e) + response = ServiceUnavailableResponse( + backend_name="Kubernetes API", + cause=str(e), + ) + raise HTTPException(**response.model_dump()) from e + except K8sConfigurationError as e: + # Cluster misconfiguration or client error - return 500 + logger.error("Cluster configuration error: %s", e) response = InternalServerErrorResponse( response="Internal server error", - cause="Unable to retrieve cluster ID", + cause=str(e), ) raise HTTPException(**response.model_dump()) from e diff --git a/src/models/responses.py b/src/models/responses.py index 4d7791da0..51ddb8d5d 100644 --- a/src/models/responses.py +++ b/src/models/responses.py @@ -2414,6 +2414,27 @@ class InternalServerErrorResponse(AbstractErrorResponse): "cause": "Failed to query the database", }, }, + { + "label": "cluster version not found", + "detail": { + "response": "Internal server error", + "cause": "ClusterVersion 'version' resource not found in OpenShift cluster", + }, + }, + { + "label": "cluster version permission denied", + "detail": { + "response": "Internal server error", + "cause": "Insufficient permissions to read ClusterVersion resource", + }, + }, + { + "label": "invalid cluster version", + "detail": { + "response": "Internal server error", + "cause": "ClusterVersion missing required field: 'clusterID'", + }, + }, ] } } @@ -2537,7 +2558,17 @@ class ServiceUnavailableResponse(AbstractErrorResponse): "response": "Unable to connect to Llama Stack", "cause": "Connection error while trying to reach backend service.", }, - } + }, + { + "label": "kubernetes api", + "detail": { + "response": "Unable to connect to Kubernetes API", + "cause": ( + "Failed to connect to Kubernetes API: " + "Service Unavailable (status 503)" + ), + }, + }, ] } } diff --git a/tests/unit/authentication/test_k8s.py b/tests/unit/authentication/test_k8s.py index a200aaad0..72f3ba135 100644 --- a/tests/unit/authentication/test_k8s.py +++ b/tests/unit/authentication/test_k8s.py @@ -3,6 +3,7 @@ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-few-public-methods,protected-access import os +from http import HTTPStatus from typing import Optional, cast import pytest @@ -13,9 +14,14 @@ from authentication.k8s import ( CLUSTER_ID_LOCAL, - ClusterIDUnavailableError, + ClusterVersionNotFoundError, + ClusterVersionPermissionError, + InvalidClusterVersionError, + K8sAPIConnectionError, + K8sConfigurationError, K8SAuthDependency, K8sClientSingleton, + get_user_info, ) from configuration import AppConfig @@ -137,7 +143,7 @@ async def test_auth_dependency_valid_token(mocker: MockerFixture) -> None: # Mock a successful token review response mock_authn_api.return_value.create_token_review.return_value = MockK8sResponse( - authenticated=True, username="valid-user", uid="valid-uid", groups=["ols-group"] + authenticated=True, username="valid-user", uid="valid-uid", groups=["lsc-group"] ) mock_authz_api.return_value.create_subject_access_review.return_value = ( MockK8sResponse(allowed=True) @@ -491,7 +497,7 @@ async def test_cluster_id_is_used_for_kube_admin(mocker: MockerFixture) -> None: allowed=True, username="kube:admin", uid="some-uuid", - groups=["ols-group"], + groups=["lsc-group"], ), ) mocker.patch( @@ -522,8 +528,8 @@ def test_auth_dependency_config(mocker: MockerFixture) -> None: ), "authz_client is not an instance of AuthorizationV1Api" -def test_get_cluster_id(mocker: MockerFixture) -> None: - """Test get_cluster_id function.""" +def test_get_cluster_id_success(mocker: MockerFixture) -> None: + """Test get_cluster_id function with successful response.""" mock_get_custom_objects_api = mocker.patch( "authentication.k8s.K8sClientSingleton.get_custom_objects_api" ) @@ -534,30 +540,181 @@ def test_get_cluster_id(mocker: MockerFixture) -> None: mock_get_custom_objects_api.return_value = mocked_call assert K8sClientSingleton._get_cluster_id() == "some-cluster-id" - # keyerror - cluster_id = {"spec": {}} + +def test_get_cluster_id_missing_cluster_id_field(mocker: MockerFixture) -> None: + """Test get_cluster_id raises InvalidClusterVersionError when clusterID is missing.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # Missing clusterID field + cluster_data: dict[str, dict[str, str]] = {"spec": {}} mocked_call = mocker.MagicMock() - mocked_call.get_cluster_custom_object.return_value = cluster_id + mocked_call.get_cluster_custom_object.return_value = cluster_data mock_get_custom_objects_api.return_value = mocked_call - with pytest.raises(ClusterIDUnavailableError, match="Failed to get cluster ID"): + + with pytest.raises( + InvalidClusterVersionError, match="Missing or invalid 'clusterID'" + ): K8sClientSingleton._get_cluster_id() - # typeerror - cluster_id = None # type: ignore + +def test_get_cluster_id_missing_spec_field(mocker: MockerFixture) -> None: + """Test get_cluster_id raises InvalidClusterVersionError when spec is missing.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # Missing spec field + cluster_data: dict[str, dict[str, str]] = {"metadata": {}} mocked_call = mocker.MagicMock() - mocked_call.get_cluster_custom_object.return_value = cluster_id + mocked_call.get_cluster_custom_object.return_value = cluster_data + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises( + InvalidClusterVersionError, + match="Missing or invalid 'spec'", + ): + K8sClientSingleton._get_cluster_id() + + +def test_get_cluster_id_invalid_type(mocker: MockerFixture) -> None: + """Test get_cluster_id handles non-dict return from API. + + If the API returns a non-dict value (e.g., None), version_data.get() will + raise AttributeError. This is caught and wrapped in InvalidClusterVersionError + by the outer exception handler (future enhancement). + + For now, we test that malformed spec dict raises InvalidClusterVersionError. + """ + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # Invalid spec type (not a dict) + cluster_data = {"spec": "invalid"} # spec should be dict + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.return_value = cluster_data + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises( + InvalidClusterVersionError, + match="Missing or invalid 'spec'", + ): + K8sClientSingleton._get_cluster_id() + + +def test_get_cluster_id_api_not_found(mocker: MockerFixture) -> None: + """Test get_cluster_id raises ClusterVersionNotFoundError for 404.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # ApiException with 404 + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.side_effect = ApiException( + status=HTTPStatus.NOT_FOUND, reason="Not Found" + ) mock_get_custom_objects_api.return_value = mocked_call - with pytest.raises(ClusterIDUnavailableError, match="Failed to get cluster ID"): + + with pytest.raises( + ClusterVersionNotFoundError, + match="ClusterVersion 'version' resource not found", + ): K8sClientSingleton._get_cluster_id() - # typeerror - mock_get_custom_objects_api.side_effect = ApiException() - with pytest.raises(ClusterIDUnavailableError, match="Failed to get cluster ID"): + +def test_get_cluster_id_api_permission_denied(mocker: MockerFixture) -> None: + """Test get_cluster_id raises ClusterVersionPermissionError for 403.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # ApiException with 403 + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.side_effect = ApiException( + status=HTTPStatus.FORBIDDEN, reason="Forbidden" + ) + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises( + ClusterVersionPermissionError, + match="Insufficient permissions to read ClusterVersion", + ): K8sClientSingleton._get_cluster_id() - # exception - mock_get_custom_objects_api.side_effect = Exception() - with pytest.raises(ClusterIDUnavailableError, match="Failed to get cluster ID"): + +def test_get_cluster_id_api_connection_error(mocker: MockerFixture) -> None: + """Test get_cluster_id raises K8sAPIConnectionError for other API errors.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # ApiException with 503 + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.side_effect = ApiException( + status=HTTPStatus.SERVICE_UNAVAILABLE, reason="Service Unavailable" + ) + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises( + K8sAPIConnectionError, match="Failed to connect to Kubernetes API" + ): + K8sClientSingleton._get_cluster_id() + + +def test_get_cluster_id_api_client_error(mocker: MockerFixture) -> None: + """Test get_cluster_id raises K8sConfigurationError for 4xx client errors.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # ApiException with 400 (client error) + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.side_effect = ApiException( + status=HTTPStatus.BAD_REQUEST, reason="Bad Request" + ) + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises(K8sConfigurationError, match="Kubernetes API request failed"): + K8sClientSingleton._get_cluster_id() + + +def test_get_cluster_id_api_rate_limit(mocker: MockerFixture) -> None: + """Test get_cluster_id raises K8sAPIConnectionError for 429 rate limit.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # ApiException with 429 (rate limit - transient error) + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.side_effect = ApiException( + status=HTTPStatus.TOO_MANY_REQUESTS, reason="Too Many Requests" + ) + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises( + K8sAPIConnectionError, match="Failed to connect to Kubernetes API" + ): + K8sClientSingleton._get_cluster_id() + + +def test_get_cluster_id_api_no_status(mocker: MockerFixture) -> None: + """Test get_cluster_id raises K8sAPIConnectionError when status is None.""" + mock_get_custom_objects_api = mocker.patch( + "authentication.k8s.K8sClientSingleton.get_custom_objects_api" + ) + + # ApiException with None status (connection/network issue) + mocked_call = mocker.MagicMock() + mocked_call.get_cluster_custom_object.side_effect = ApiException( + status=None, reason="Connection failed" + ) + mock_get_custom_objects_api.return_value = mocked_call + + with pytest.raises( + K8sAPIConnectionError, match="Failed to connect to Kubernetes API" + ): K8sClientSingleton._get_cluster_id() @@ -581,3 +738,243 @@ def test_get_cluster_id_outside_of_cluster(mocker: MockerFixture) -> None: # ensure cluster_id is None to trigger the condition K8sClientSingleton._cluster_id = None assert K8sClientSingleton.get_cluster_id() == CLUSTER_ID_LOCAL + + +async def test_kube_admin_cluster_id_api_connection_error_returns_503( + mocker: MockerFixture, +) -> None: + """Test kube:admin flow returns 503 when K8s API is unreachable.""" + dependency = K8SAuthDependency() + mock_authz_api = mocker.patch("authentication.k8s.K8sClientSingleton.get_authz_api") + mock_authz_api.return_value.create_subject_access_review.return_value = ( + MockK8sResponse(allowed=True) + ) + + request = Request( + scope={ + "type": "http", + "headers": [(b"authorization", b"Bearer valid-token")], + } + ) + + mocker.patch( + "authentication.k8s.get_user_info", + return_value=MockK8sResponseStatus( + authenticated=True, + allowed=True, + username="kube:admin", + uid="some-uuid", + groups=["lsc-group"], + ), + ) + + # Mock K8s API connection error + mocker.patch( + "authentication.k8s.K8sClientSingleton.get_cluster_id", + side_effect=K8sAPIConnectionError( + "Failed to connect to Kubernetes API: Service Unavailable (status 503)" + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await dependency(request) + + # Should return 503 Service Unavailable + assert exc_info.value.status_code == 503 + detail = cast(dict[str, str], exc_info.value.detail) + assert detail["response"] == "Unable to connect to Kubernetes API" + assert "Service Unavailable" in detail["cause"] + + +async def test_kube_admin_cluster_version_not_found_returns_500( + mocker: MockerFixture, +) -> None: + """Test kube:admin flow returns 500 when ClusterVersion doesn't exist.""" + dependency = K8SAuthDependency() + mock_authz_api = mocker.patch("authentication.k8s.K8sClientSingleton.get_authz_api") + mock_authz_api.return_value.create_subject_access_review.return_value = ( + MockK8sResponse(allowed=True) + ) + + request = Request( + scope={ + "type": "http", + "headers": [(b"authorization", b"Bearer valid-token")], + } + ) + + mocker.patch( + "authentication.k8s.get_user_info", + return_value=MockK8sResponseStatus( + authenticated=True, + allowed=True, + username="kube:admin", + uid="some-uuid", + groups=["lsc-group"], + ), + ) + + # Mock ClusterVersion not found (404) + mocker.patch( + "authentication.k8s.K8sClientSingleton.get_cluster_id", + side_effect=ClusterVersionNotFoundError( + "ClusterVersion 'version' resource not found in OpenShift cluster" + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await dependency(request) + + # Should return 500 Internal Server Error + assert exc_info.value.status_code == 500 + detail = cast(dict[str, str], exc_info.value.detail) + assert detail["response"] == "Internal server error" + assert "ClusterVersion 'version' resource not found" in detail["cause"] + + +async def test_kube_admin_cluster_version_permission_error_returns_500( + mocker: MockerFixture, +) -> None: + """Test kube:admin flow returns 500 when permission to ClusterVersion is denied.""" + dependency = K8SAuthDependency() + mock_authz_api = mocker.patch("authentication.k8s.K8sClientSingleton.get_authz_api") + mock_authz_api.return_value.create_subject_access_review.return_value = ( + MockK8sResponse(allowed=True) + ) + + request = Request( + scope={ + "type": "http", + "headers": [(b"authorization", b"Bearer valid-token")], + } + ) + + mocker.patch( + "authentication.k8s.get_user_info", + return_value=MockK8sResponseStatus( + authenticated=True, + allowed=True, + username="kube:admin", + uid="some-uuid", + groups=["lsc-group"], + ), + ) + + # Mock ClusterVersion permission denied (403) + mocker.patch( + "authentication.k8s.K8sClientSingleton.get_cluster_id", + side_effect=ClusterVersionPermissionError( + "Insufficient permissions to read ClusterVersion resource" + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await dependency(request) + + # Should return 500 Internal Server Error + assert exc_info.value.status_code == 500 + detail = cast(dict[str, str], exc_info.value.detail) + assert detail["response"] == "Internal server error" + assert "Insufficient permissions" in detail["cause"] + + +async def test_kube_admin_invalid_cluster_version_returns_500( + mocker: MockerFixture, +) -> None: + """Test kube:admin flow returns 500 when ClusterVersion has invalid structure.""" + dependency = K8SAuthDependency() + mock_authz_api = mocker.patch("authentication.k8s.K8sClientSingleton.get_authz_api") + mock_authz_api.return_value.create_subject_access_review.return_value = ( + MockK8sResponse(allowed=True) + ) + + request = Request( + scope={ + "type": "http", + "headers": [(b"authorization", b"Bearer valid-token")], + } + ) + + mocker.patch( + "authentication.k8s.get_user_info", + return_value=MockK8sResponseStatus( + authenticated=True, + allowed=True, + username="kube:admin", + uid="some-uuid", + groups=["lsc-group"], + ), + ) + + # Mock invalid ClusterVersion structure + mocker.patch( + "authentication.k8s.K8sClientSingleton.get_cluster_id", + side_effect=InvalidClusterVersionError( + "ClusterVersion missing required field: 'clusterID'" + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await dependency(request) + + # Should return 500 Internal Server Error + assert exc_info.value.status_code == 500 + detail = cast(dict[str, str], exc_info.value.detail) + assert detail["response"] == "Internal server error" + assert "ClusterVersion missing required field" in detail["cause"] + + +@pytest.mark.parametrize( + "api_status,reason,expected_status,expected_response,expected_cause_fragment", + [ + ( + HTTPStatus.SERVICE_UNAVAILABLE, + "Service Unavailable", + 503, + "Unable to connect to Kubernetes API", + "Service Unavailable", + ), + ( + HTTPStatus.TOO_MANY_REQUESTS, + "Too Many Requests", + 503, + "Unable to connect to Kubernetes API", + "Too Many Requests", + ), + ( + None, + "Connection failed", + 503, + "Unable to connect to Kubernetes API", + "Connection failed", + ), + ( + HTTPStatus.BAD_REQUEST, + "Bad Request", + 500, + "Internal server error", + "Bad Request", + ), + ], +) +def test_get_user_info_api_error_handling( + mocker: MockerFixture, + api_status: Optional[int], + reason: str, + expected_status: int, + expected_response: str, + expected_cause_fragment: str, +) -> None: + """Test get_user_info properly handles Kubernetes API errors.""" + mock_authn_api = mocker.patch("authentication.k8s.K8sClientSingleton.get_authn_api") + mock_authn_api.return_value.create_token_review.side_effect = ApiException( + status=api_status, reason=reason + ) + + with pytest.raises(HTTPException) as exc_info: + get_user_info("some-token") + + assert exc_info.value.status_code == expected_status + detail = cast(dict[str, str], exc_info.value.detail) + assert detail["response"] == expected_response + assert expected_cause_fragment in detail["cause"] diff --git a/tests/unit/models/responses/test_error_responses.py b/tests/unit/models/responses/test_error_responses.py index f1a257e93..cbb15bc6f 100644 --- a/tests/unit/models/responses/test_error_responses.py +++ b/tests/unit/models/responses/test_error_responses.py @@ -588,7 +588,7 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 6 + assert expected_count == 9 # Verify all labeled examples are present assert "internal" in examples @@ -597,6 +597,9 @@ def test_openapi_response(self) -> None: assert "query" in examples assert "conversation cache" in examples assert "database" in examples + assert "cluster version not found" in examples + assert "cluster version permission denied" in examples + assert "invalid cluster version" in examples # Verify example structure for one example internal_example = examples["internal"] @@ -656,10 +659,11 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 1 + assert expected_count == 2 # Verify example structure assert "llama stack" in examples + assert "kubernetes api" in examples llama_example = examples["llama stack"] assert "value" in llama_example assert "detail" in llama_example["value"]