Add a reauth flow to the Airly integration (#182797)

This commit is contained in:
Maciej Bieniek
2026-09-27 13:23:24 +02:00
committed by GitHub
parent f2aa8c469e
commit 406b04a069
6 changed files with 259 additions and 58 deletions
+85 -54
View File
@@ -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, {}
@@ -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",
@@ -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
@@ -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"
},
+118 -1
View File
@@ -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"
+32 -2
View File
@@ -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."""