1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add reauthentication and reconfiguration flows in Google Weather to reach platinum (#166106)

This commit is contained in:
tronikos
2026-04-10 10:55:33 -07:00
committed by GitHub
parent 7690d9570c
commit 99e4c87f5e
8 changed files with 464 additions and 26 deletions
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -9,6 +10,9 @@ from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
@@ -81,11 +85,16 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
def _is_location_already_configured(
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
hass: HomeAssistant,
new_data: dict[str, float],
epsilon: float = 1e-4,
exclude_subentry_id: str | None = None,
) -> bool:
"""Check if the location is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if exclude_subentry_id and subentry.subentry_id == exclude_subentry_id:
continue
# A more accurate way is to use the haversine formula, but for simplicity
# we use a simple distance check. The epsilon value is small anyway.
# This is mostly to capture cases where the user has slightly moved the location pin.
@@ -106,7 +115,7 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
"""Handle a flow initialized by the user, reauth or reconfigure."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
@@ -116,21 +125,45 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
api_key = user_input[CONF_API_KEY]
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
entry = (
self._get_reauth_entry()
if self.source == SOURCE_REAUTH
else self._get_reconfigure_entry()
)
subentry = next(iter(entry.subentries.values()), None)
if subentry:
latitude = subentry.data[CONF_LATITUDE]
longitude = subentry.data[CONF_LONGITUDE]
else:
latitude = self.hass.config.latitude
longitude = self.hass.config.longitude
validation_input = {
CONF_LOCATION: {CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}
}
else:
if _is_location_already_configured(
self.hass, user_input[CONF_LOCATION]
):
return self.async_abort(reason="already_configured")
validation_input = user_input
api = GoogleWeatherApi(
session=async_get_clientsession(self.hass),
api_key=api_key,
referrer=referrer,
language_code=self.hass.config.language,
)
if await _validate_input(user_input, api, errors, description_placeholders):
if await _validate_input(
validation_input, api, errors, description_placeholders
):
data = {CONF_API_KEY: api_key, CONF_REFERRER: referrer}
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
return self.async_update_reload_and_abort(entry, data=data)
return self.async_create_entry(
title="Google Weather",
data={
CONF_API_KEY: api_key,
CONF_REFERRER: referrer,
},
data=data,
subentries=[
{
"subentry_type": "location",
@@ -140,19 +173,47 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
},
],
)
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
entry = (
self._get_reauth_entry()
if self.source == SOURCE_REAUTH
else self._get_reconfigure_entry()
)
if user_input is None:
user_input = {
CONF_API_KEY: entry.data.get(CONF_API_KEY),
SECTION_API_KEY_OPTIONS: {
CONF_REFERRER: entry.data.get(CONF_REFERRER)
},
}
schema = STEP_USER_DATA_SCHEMA
else:
user_input = {}
schema = STEP_USER_DATA_SCHEMA.schema.copy()
schema.update(_get_location_schema(self.hass).schema)
if user_input is None:
user_input = {}
schema_dict = STEP_USER_DATA_SCHEMA.schema.copy()
schema_dict.update(_get_location_schema(self.hass).schema)
schema = vol.Schema(schema_dict)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema), user_input
),
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow."""
return await self.async_step_user(user_input)
@classmethod
@callback
def async_get_supported_subentry_types(
@@ -165,6 +226,11 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
class LocationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for location."""
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == SOURCE_USER
async def async_step_location(
self,
user_input: dict[str, Any] | None = None,
@@ -176,16 +242,35 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
exclude_id = (
None if self._is_new else self._get_reconfigure_subentry().subentry_id
)
if _is_location_already_configured(
self.hass, user_input[CONF_LOCATION], exclude_subentry_id=exclude_id
):
return self.async_abort(reason="already_configured")
api: GoogleWeatherApi = self._get_entry().runtime_data.api
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
if self._is_new:
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input[CONF_LOCATION],
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
title=user_input[CONF_NAME],
data=user_input[CONF_LOCATION],
)
else:
elif self._is_new:
user_input = {}
else:
subentry = self._get_reconfigure_subentry()
user_input = {
CONF_NAME: subentry.title,
CONF_LOCATION: dict(subentry.data),
}
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
@@ -196,3 +281,4 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
)
async_step_user = async_step_location
async_step_reconfigure = async_step_location
@@ -12,6 +12,7 @@ from google_weather_api import (
CurrentConditionsResponse,
DailyForecastResponse,
GoogleWeatherApi,
GoogleWeatherApiAuthError,
GoogleWeatherApiError,
HourlyForecastResponse,
)
@@ -19,6 +20,7 @@ from google_weather_api import (
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
@@ -92,6 +94,14 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
self.subentry.data[CONF_LATITUDE],
self.subentry.data[CONF_LONGITUDE],
)
except GoogleWeatherApiAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders={
"error": str(err),
},
) from err
except GoogleWeatherApiError as err:
_LOGGER.error(
"Error fetching %s for %s: %s",
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_weather_api"],
"quality_scale": "bronze",
"quality_scale": "platinum",
"requirements": ["python-google-weather-api==0.0.6"]
}
@@ -38,7 +38,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
@@ -68,7 +68,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repairs.
@@ -1,7 +1,9 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
@@ -38,7 +40,8 @@
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"entry_not_loaded": "Cannot add things while the configuration is disabled."
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Location",
"error": {
@@ -46,6 +49,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"reconfigure": "Reconfigure location",
"user": "Add location"
},
"step": {
@@ -100,6 +104,9 @@
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed: {error}"
},
"update_error": {
"message": "Error fetching weather data: {error}"
}
@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock
from google_weather_api import GoogleWeatherApiError
from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError
import pytest
from homeassistant import config_entries
@@ -359,6 +359,294 @@ async def test_subentry_flow_location_already_configured(
assert len(entry.subentries) == 1
async def test_reauth(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test reauth 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"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
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.get(CONF_REFERRER) is None
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("api_exception", "expected_error", "expected_placeholders"),
[
(
GoogleWeatherApiAuthError("Invalid API key"),
"cannot_connect",
{
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
"error_message": "Invalid API key",
"name": "Google Weather",
},
),
(
ValueError(),
"unknown",
{
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
"name": "Google Weather",
},
),
],
)
async def test_reauth_exceptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
api_exception: Exception,
expected_error: str,
expected_placeholders: dict[str, str],
) -> None:
"""Test reauth flow with exceptions."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
mock_google_weather_api.async_get_current_conditions.side_effect = api_exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "invalid-api-key",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
assert result["description_placeholders"] == expected_placeholders
mock_google_weather_api.async_get_current_conditions.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "valid-api-key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reauth_same_api_key_different_referrer(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test reauth flow with same API key but different referrer."""
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"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "test-api-key",
SECTION_API_KEY_OPTIONS: {
CONF_REFERRER: "new-referrer",
},
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "test-api-key"
assert mock_config_entry.data.get(CONF_REFERRER) == "new-referrer"
async def test_reconfigure(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test reconfigure flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "new-api-key",
SECTION_API_KEY_OPTIONS: {
CONF_REFERRER: "new-referrer",
},
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new-api-key"
assert mock_config_entry.data.get(CONF_REFERRER) == "new-referrer"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("api_exception", "expected_error", "expected_placeholders"),
[
(
GoogleWeatherApiAuthError("Invalid API key"),
"cannot_connect",
{
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
"error_message": "Invalid API key",
},
),
(
ValueError(),
"unknown",
{
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
},
),
],
)
async def test_reconfigure_exceptions(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
api_exception: Exception,
expected_error: str,
expected_placeholders: dict[str, str],
) -> None:
"""Test reconfigure flow with exceptions."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
mock_google_weather_api.async_get_current_conditions.side_effect = api_exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "invalid-api-key",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
assert result["description_placeholders"] == expected_placeholders
mock_google_weather_api.async_get_current_conditions.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "valid-api-key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert len(mock_setup_entry.mock_calls) == 1
async def test_reconfigure_no_subentries(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test reconfigure flow when there are no subentries."""
mock_config_entry = MockConfigEntry(
title="Google Weather",
domain=DOMAIN,
data={
CONF_API_KEY: "test-api-key",
},
)
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "new-api-key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new-api-key"
assert len(mock_setup_entry.mock_calls) == 1
async def test_subentry_reconfigure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test reconfiguring a location subentry."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
subentry = next(iter(mock_config_entry.subentries.values()))
result = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "location"
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_NAME: "New Work",
CONF_LOCATION: {
CONF_LATITUDE: 30.1,
CONF_LONGITUDE: 40.1,
},
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reconfigure_successful"
# Reload the entry to see changes
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
updated_subentry = entry.subentries[subentry.subentry_id]
assert updated_subentry.title == "New Work"
assert updated_subentry.data == {
CONF_LATITUDE: 30.1,
CONF_LONGITUDE: 40.1,
}
async def test_subentry_flow_entry_not_loaded(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
+20 -1
View File
@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock
from google_weather_api import GoogleWeatherApiError
from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError
import pytest
from homeassistant.components.google_weather.const import DOMAIN
@@ -56,6 +56,25 @@ async def test_config_not_ready(
assert "Error fetching weather data: API error" in caplog.text
async def test_setup_auth_failed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test auth failed during setup."""
mock_google_weather_api.async_get_current_conditions.side_effect = (
GoogleWeatherApiAuthError()
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(
flow["step_id"] == "user"
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
)
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
+29 -1
View File
@@ -4,14 +4,16 @@ from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from google_weather_api import GoogleWeatherApiError
from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.google_weather.const import DOMAIN
from homeassistant.components.homeassistant import (
DOMAIN as HOMEASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
@@ -170,3 +172,29 @@ async def test_state_update(
state = hass.states.get(entity_id)
assert state
assert state.state == "15.0"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_auth_failure_during_update(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure that we start a reauth flow when auth fails during an update."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_google_weather_api.async_get_current_conditions.side_effect = (
GoogleWeatherApiAuthError()
)
freezer.tick(timedelta(minutes=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert any(
flow["step_id"] == "user"
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
)