diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..3220b9a --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core application helpers.""" diff --git a/app/core/exception_handlers.py b/app/core/exception_handlers.py new file mode 100644 index 0000000..6e89857 --- /dev/null +++ b/app/core/exception_handlers.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + +from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError, HaTimeoutError + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(HaClientError) + async def handle_ha_client_error(_: Request, exc: HaClientError) -> JSONResponse: + return JSONResponse( + status_code=_status_code_for_ha_error(exc), + content={"detail": exc.public_detail}, + ) + + +def _status_code_for_ha_error(exc: HaClientError) -> int: + if isinstance(exc, HaTimeoutError): + return status.HTTP_504_GATEWAY_TIMEOUT + if isinstance(exc, (HaAuthError, HaHttpError)): + return status.HTTP_502_BAD_GATEWAY + return status.HTTP_502_BAD_GATEWAY diff --git a/app/ha/client.py b/app/ha/client.py index e952a4f..44558bd 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -6,6 +6,13 @@ from typing import Any import requests +from app.ha.exceptions import ( + HaAuthError, + HaHttpError, + HaTimeoutError, + HaUnexpectedPayloadError, +) + logger = logging.getLogger(__name__) @@ -26,13 +33,35 @@ class HaClient: }) def list_entities(self) -> list[dict[str, Any]]: - response = self._session.get( - f"{self._settings.url}/api/states", - timeout=self._settings.timeout_seconds, - ) - response.raise_for_status() - payload = response.json() + try: + response = self._session.get( + f"{self._settings.url}/api/states", + timeout=self._settings.timeout_seconds, + ) + except requests.Timeout as exc: + raise HaTimeoutError("Home Assistant request timed out.") from exc + except requests.RequestException as exc: + raise HaHttpError(status_code=502, message="Home Assistant request failed.") from exc + + if response.status_code in {401, 403}: + raise HaAuthError( + status_code=response.status_code, + message="Home Assistant authentication failed.", + ) + + try: + response.raise_for_status() + except requests.HTTPError as exc: + raise HaHttpError( + status_code=response.status_code, + message="Home Assistant returned an HTTP error.", + ) from exc + + try: + payload = response.json() + except ValueError as exc: + raise HaUnexpectedPayloadError("Home Assistant returned invalid JSON.") from exc + if not isinstance(payload, list): - msg = "Home Assistant states response must be a list." - raise TypeError(msg) + raise HaUnexpectedPayloadError("Home Assistant states response must be a list.") return payload diff --git a/app/ha/exceptions.py b/app/ha/exceptions.py new file mode 100644 index 0000000..64ae389 --- /dev/null +++ b/app/ha/exceptions.py @@ -0,0 +1,27 @@ +from __future__ import annotations + + +class HaClientError(Exception): + """Base class for Home Assistant integration failures.""" + + public_detail = "Home Assistant is currently unavailable." + + +class HaTimeoutError(HaClientError): + public_detail = "Home Assistant request timed out." + + +class HaHttpError(HaClientError): + public_detail = "Home Assistant returned an error." + + def __init__(self, status_code: int, message: str | None = None) -> None: + super().__init__(message or self.public_detail) + self.status_code = status_code + + +class HaAuthError(HaHttpError): + public_detail = "Home Assistant authentication failed." + + +class HaUnexpectedPayloadError(HaClientError): + public_detail = "Home Assistant returned an unexpected response." diff --git a/app/main.py b/app/main.py index 72d6839..55b06a5 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from app.api.v1.entities import router as entities_router from app.config import load_settings +from app.core.exception_handlers import register_exception_handlers from app.ha.client import HaClient, HaClientSettings from app.ha.reader import HaReader @@ -31,6 +32,7 @@ app = FastAPI( lifespan=lifespan, ) +register_exception_handlers(app) app.include_router(entities_router) diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index 5db1e29..6df2771 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from fastapi.testclient import TestClient +from app.ha.exceptions import HaTimeoutError from app.ha.models import HaEntitySummary from app.ha.reader import HaReader from app.main import app @@ -15,6 +16,14 @@ class FakeHaReader(HaReader): return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")] +class TimeoutHaReader(HaReader): + def __init__(self) -> None: + pass + + def read_entities(self) -> Sequence[HaEntitySummary]: + raise HaTimeoutError("contains internal details that must not leak") + + def test_openapi_docs_are_available() -> None: with TestClient(app) as client: response = client.get("/docs") @@ -44,3 +53,11 @@ def test_entities_returns_503_without_home_assistant_config() -> None: delattr(app.state, "ha_reader") response = client.get("/v1/entities") assert response.status_code == 503 + + +def test_entities_maps_ha_errors_without_leaking_details() -> None: + with TestClient(app) as client: + app.state.ha_reader = TimeoutHaReader() + response = client.get("/v1/entities") + assert response.status_code == 504 + assert response.json() == {"detail": "Home Assistant request timed out."} diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py new file mode 100644 index 0000000..131d606 --- /dev/null +++ b/tests/ha/test_ha_client.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +import requests + +from app.ha.client import HaClient, HaClientSettings +from app.ha.exceptions import ( + HaAuthError, + HaHttpError, + HaTimeoutError, + HaUnexpectedPayloadError, +) + + +def _client_with_response(response: Mock) -> HaClient: + client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token")) + client._session.get = Mock(return_value=response) # type: ignore[method-assign] + return client + + +def _response(status_code: int = 200, payload: object | None = None) -> Mock: + response = Mock() + response.status_code = status_code + response.json.return_value = [] if payload is None else payload + if status_code >= 400: + response.raise_for_status.side_effect = requests.HTTPError("upstream failed") + return response + + +def test_list_entities_returns_home_assistant_payload() -> None: + payload = [{"entity_id": "sensor.temperature", "state": "21"}] + client = _client_with_response(_response(payload=payload)) + + assert client.list_entities() == payload + + +def test_list_entities_maps_timeout() -> None: + client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token")) + client._session.get = Mock(side_effect=requests.Timeout("secret-token")) # type: ignore[method-assign] + + with pytest.raises(HaTimeoutError): + client.list_entities() + + +@pytest.mark.parametrize("status_code", [401, 403]) +def test_list_entities_maps_auth_errors(status_code: int) -> None: + client = _client_with_response(_response(status_code=status_code)) + + with pytest.raises(HaAuthError) as exc_info: + client.list_entities() + + assert exc_info.value.status_code == status_code + + +def test_list_entities_maps_http_errors() -> None: + client = _client_with_response(_response(status_code=500)) + + with pytest.raises(HaHttpError) as exc_info: + client.list_entities() + + assert exc_info.value.status_code == 500 + + +def test_list_entities_rejects_invalid_json() -> None: + response = _response() + response.json.side_effect = ValueError("not json") + client = _client_with_response(response) + + with pytest.raises(HaUnexpectedPayloadError): + client.list_entities() + + +def test_list_entities_rejects_non_list_payload() -> None: + client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"})) + + with pytest.raises(HaUnexpectedPayloadError): + client.list_entities()