mirror of
https://github.com/home-assistant/core.git
synced 2026-10-07 06:50:41 -04:00
Add a reauth flow to the Airly integration (#182797)
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user