From 406b04a06967f61ae8f30558aa4dc4ef49b521d2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 27 Sep 2026 13:23:24 +0200 Subject: [PATCH] Add a reauth flow to the Airly integration (#182797) --- homeassistant/components/airly/config_flow.py | 139 +++++++++++------- homeassistant/components/airly/coordinator.py | 11 ++ .../components/airly/quality_scale.yaml | 2 +- homeassistant/components/airly/strings.json | 12 ++ tests/components/airly/test_config_flow.py | 119 ++++++++++++++- tests/components/airly/test_init.py | 34 ++++- 6 files changed, 259 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 97d80d60600b..5552a95dec36 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -1,11 +1,12 @@ """Adds config flow for Airly.""" from asyncio import timeout +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any, override -from aiohttp import ClientConnectorError, ClientSession +from aiohttp import ClientConnectorError from airly import Airly from airly.exceptions import AirlyError import probatio @@ -39,48 +40,32 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} use_nearest = False - websession = async_get_clientsession(self.hass) - if user_input is not None: await self.async_set_unique_id( f"{user_input[CONF_LATITUDE]}-{user_input[CONF_LONGITUDE]}" ) self._abort_if_unique_id_configured() - try: - location_point_valid = await check_location( - websession, + + location_valid, errors = await self.async_check_location( + user_input[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], + ) + if not location_valid and not errors: + location_valid, errors = await self.async_check_location( user_input[CONF_API_KEY], user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE], + use_nearest=True, ) - if not location_point_valid: - location_nearest_valid = await check_location( - websession, - user_input[CONF_API_KEY], - user_input[CONF_LATITUDE], - user_input[CONF_LONGITUDE], - use_nearest=True, - ) - except AirlyError as err: - if err.status_code == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_api_key" - elif err.status_code == HTTPStatus.NOT_FOUND: - errors["base"] = "wrong_location" - else: - errors["base"] = "unknown" - except ClientConnectorError, TimeoutError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - if not location_point_valid: - if not location_nearest_valid: - return self.async_abort(reason="wrong_location") - use_nearest = True + use_nearest = location_valid + + if not errors: + if not location_valid: + return self.async_abort(reason="wrong_location") return self.async_create_entry( title=DEFAULT_NAME, data={**user_input, CONF_USE_NEAREST: use_nearest}, @@ -103,29 +88,75 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): description_placeholders=DESCRIPTION_PLACEHOLDERS, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + return await self.async_step_reauth_confirm() -async def check_location( - client: ClientSession, - api_key: str, - latitude: float, - longitude: float, - use_nearest: bool = False, -) -> bool: - """Return true if location is valid.""" - airly = Airly(api_key, client) - if use_nearest: - measurements = airly.create_measurements_session_nearest( - latitude=latitude, longitude=longitude, max_distance_km=5 + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + _, errors = await self.async_check_location( + user_input[CONF_API_KEY], + reauth_entry.data[CONF_LATITUDE], + reauth_entry.data[CONF_LONGITUDE], + use_nearest=reauth_entry.data.get(CONF_USE_NEAREST, False), + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=probatio.Schema({probatio.Required(CONF_API_KEY): str}), + errors=errors, + description_placeholders=DESCRIPTION_PLACEHOLDERS, ) - else: - measurements = airly.create_measurements_session_point( - latitude=latitude, longitude=longitude + + async def async_check_location( + self, + api_key: str, + latitude: float, + longitude: float, + use_nearest: bool = False, + ) -> tuple[bool, dict[str, str]]: + """Check the location and return its validity along with flow errors.""" + websession = async_get_clientsession(self.hass) + airly = Airly(api_key, websession) + + if use_nearest: + measurements = airly.create_measurements_session_nearest( + latitude=latitude, longitude=longitude, max_distance_km=5 + ) + else: + measurements = airly.create_measurements_session_point( + latitude=latitude, longitude=longitude + ) + + try: + async with timeout(DEFAULT_TIMEOUT): + await measurements.update() + except AirlyError as err: + if err.status_code == HTTPStatus.UNAUTHORIZED: + return False, {"base": "invalid_api_key"} + if err.status_code == HTTPStatus.NOT_FOUND: + return False, {"base": "wrong_location"} + return False, {"base": "unknown"} + except ClientConnectorError, TimeoutError: + return False, {"base": "cannot_connect"} + except Exception: + _LOGGER.exception("Unexpected exception") + return False, {"base": "unknown"} + + location_valid = ( + measurements.current["indexes"][0]["description"] != NO_AIRLY_SENSORS ) - async with timeout(DEFAULT_TIMEOUT): - await measurements.update() - current = measurements.current - - if current["indexes"][0]["description"] == NO_AIRLY_SENSORS: - return False - return True + return location_valid, {} diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index cac9c25cb234..b234096696cd 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -2,6 +2,7 @@ from asyncio import timeout from datetime import timedelta +from http import HTTPStatus import logging from math import ceil from typing import override @@ -13,6 +14,7 @@ from airly.exceptions import AirlyError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -107,6 +109,15 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i async with timeout(DEFAULT_TIMEOUT): await self.measurements.update() except (AirlyError, ClientConnectorError, TimeoutError) as error: + if ( + isinstance(error, AirlyError) + and error.status_code == HTTPStatus.UNAUTHORIZED + ): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": self.config_entry.title}, + ) from error raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_error", diff --git a/homeassistant/components/airly/quality_scale.yaml b/homeassistant/components/airly/quality_scale.yaml index d71dcb4c1cf3..d2b0f4ca9f10 100644 --- a/homeassistant/components/airly/quality_scale.yaml +++ b/homeassistant/components/airly/quality_scale.yaml @@ -42,7 +42,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 2348a537cddf..bf0d950fa7c0 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "wrong_location": "[%key:component::airly::config::error::wrong_location%]" }, "error": { @@ -11,6 +12,14 @@ "wrong_location": "No Airly measuring stations in this area." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::airly::config::step::user::data_description::api_key%]" + } + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -33,6 +42,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication failed for {entry}, please update your API key" + }, "no_station": { "message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area" }, diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index f5b190b310ab..5a13b880084d 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -221,7 +221,7 @@ async def test_create_entry_with_nearest_method( @pytest.mark.parametrize( ("exception", "error"), [ - (TimeoutError(), "cannot_connect"), + (TimeoutError, "cannot_connect"), (ClientConnectorError(Mock(), OSError("test")), "cannot_connect"), ], ) @@ -305,3 +305,120 @@ async def test_unknown_error( ) assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, +) -> None: + """Test starting a reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "new_api_key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + assert mock_config_entry.data[CONF_LATITUDE] == 12.3 + assert mock_config_entry.data[CONF_LONGITUDE] == 45.6 + mock_airly_client.create_measurements_session_point.assert_called_once_with( + latitude=12.3, longitude=45.6 + ) + + +async def test_reauth_with_nearest_method( + hass: HomeAssistant, + mock_airly_client: MagicMock, +) -> None: + """Test that reauthentication validates the API key with the nearest method.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="12.3-45.6", + data={**CONFIG, CONF_USE_NEAREST: True}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "new_api_key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_API_KEY] == "new_api_key" + mock_airly_client.create_measurements_session_nearest.assert_called_once_with( + latitude=12.3, longitude=45.6, max_distance_km=5 + ) + mock_airly_client.create_measurements_session_point.assert_not_called() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + AirlyError( + HTTPStatus.UNAUTHORIZED, + {"message": "Invalid authentication credentials"}, + ), + "invalid_api_key", + ), + ( + AirlyError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "Server error"}), + "unknown", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectorError(Mock(), OSError("test")), "cannot_connect"), + (Exception("unexpected"), "unknown"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test reauthentication flow with errors.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + point_measurements = ( + mock_airly_client.create_measurements_session_point.return_value + ) + point_measurements.update.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "new_api_key"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + assert mock_config_entry.data[CONF_API_KEY] == "foo" + + point_measurements.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "new_api_key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 2c675d807398..1e48c5fd5fc1 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -1,8 +1,10 @@ """Test init of Airly integration.""" +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock +from airly.exceptions import AirlyError from airly.measurements import Measurement from freezegun.api import FrozenDateTimeFactory import pytest @@ -10,7 +12,7 @@ import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_DOMAIN from homeassistant.components.airly.const import CONF_USE_NEAREST, DOMAIN from homeassistant.components.airly.coordinator import set_update_interval -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -69,18 +71,46 @@ async def test_async_setup_entry_with_nearest( assert state.state == "4.37" +@pytest.mark.parametrize( + "exception", + [ + ConnectionError, + AirlyError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "Server error"}), + ], +) async def test_config_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_airly_client: MagicMock, + exception: Exception, ) -> None: """Test for setup failure if connection to Airly is missing.""" - mock_airly_client.create_measurements_session_point.return_value.update.side_effect = ConnectionError() + mock_airly_client.create_measurements_session_point.return_value.update.side_effect = exception await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airly_client: MagicMock, +) -> None: + """Test that a reauth flow is started when the API key is invalid.""" + mock_airly_client.create_measurements_session_point.return_value.update.side_effect = AirlyError( + HTTPStatus.UNAUTHORIZED, {"message": "Invalid authentication credentials"} + ) + + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + assert flows[0]["step_id"] == "reauth_confirm" + + @pytest.mark.usefixtures("mock_airly_client") async def test_config_without_unique_id(hass: HomeAssistant) -> None: """Test for setup entry without unique_id."""