From 385576bfb20bf6e6f3f9ba46a4ad74e1806ce3cd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 11 Jul 2024 00:24:48 +0200 Subject: [PATCH] Add reauth flow to Mealie (#121697) --- homeassistant/components/mealie/__init__.py | 8 +- .../components/mealie/config_flow.py | 109 +++++++++++++----- .../components/mealie/coordinator.py | 8 +- homeassistant/components/mealie/strings.json | 10 +- tests/components/mealie/test_config_flow.py | 107 ++++++++++++++++- 5 files changed, 205 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index 57f75e7d61e..8cf15316121 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -6,7 +6,11 @@ from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionE from homeassistant.const import CONF_API_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType @@ -44,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo about = await client.get_about() version = create_version(about.version) except MealieAuthenticationError as error: - raise ConfigEntryError("Authentication failed") from error + raise ConfigEntryAuthFailed from error except MealieConnectionError as error: raise ConfigEntryNotReady(error) from error diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py index 6da423cdc26..53266d08c2e 100644 --- a/homeassistant/components/mealie/config_flow.py +++ b/homeassistant/components/mealie/config_flow.py @@ -1,62 +1,115 @@ """Config flow for Mealie.""" +from collections.abc import Mapping from typing import Any from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION from .utils import create_version -SCHEMA = vol.Schema( +USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_API_TOKEN): str, } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) class MealieConfigFlow(ConfigFlow, domain=DOMAIN): """Mealie config flow.""" + host: str | None = None + entry: ConfigEntry | None = None + + async def check_connection( + self, api_token: str + ) -> tuple[dict[str, str], str | None]: + """Check connection to the Mealie API.""" + assert self.host is not None + client = MealieClient( + self.host, + token=api_token, + session=async_get_clientsession(self.hass), + ) + try: + info = await client.get_user_info() + about = await client.get_about() + version = create_version(about.version) + except MealieConnectionError: + return {"base": "cannot_connect"}, None + except MealieAuthenticationError: + return {"base": "invalid_auth"}, None + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + return {"base": "unknown"}, None + if not version.valid or version < MIN_REQUIRED_MEALIE_VERSION: + return {"base": "mealie_version"}, None + return {}, info.user_id + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input: - client = MealieClient( - user_input[CONF_HOST], - token=user_input[CONF_API_TOKEN], - session=async_get_clientsession(self.hass), + self.host = user_input[CONF_HOST] + errors, user_id = await self.check_connection( + user_input[CONF_API_TOKEN], ) - try: - info = await client.get_user_info() - about = await client.get_about() - version = create_version(about.version) - except MealieConnectionError: - errors["base"] = "cannot_connect" - except MealieAuthenticationError: - errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected error") - errors["base"] = "unknown" - else: - if not version.valid or version < MIN_REQUIRED_MEALIE_VERSION: - errors["base"] = "mealie_version" - else: - await self.async_set_unique_id(info.user_id) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title="Mealie", - data=user_input, - ) + if not errors: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Mealie", + data=user_input, + ) return self.async_show_form( step_id="user", - data_schema=SCHEMA, + data_schema=USER_SCHEMA, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.host = entry_data[CONF_HOST] + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + if user_input: + errors, user_id = await self.check_connection( + user_input[CONF_API_TOKEN], + ) + if not errors: + assert self.entry + if self.entry.unique_id == user_id: + return self.async_update_reload_and_abort( + self.entry, + data={ + **self.entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + }, + ) + return self.async_abort(reason="wrong_account") + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, errors=errors, ) diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index 135100e1b07..c7a00673929 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -17,7 +17,7 @@ from aiomealie import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed import homeassistant.util.dt as dt_util @@ -82,7 +82,7 @@ class MealieMealplanCoordinator( await self.client.get_mealplans(dt_util.now().date(), next_week.date()) ).items except MealieAuthenticationError as error: - raise ConfigEntryError("Authentication failed") from error + raise ConfigEntryAuthFailed from error except MealieConnectionError as error: raise UpdateFailed(error) from error res: dict[MealplanEntryType, list[Mealplan]] = { @@ -116,7 +116,7 @@ class MealieShoppingListCoordinator( try: self.shopping_lists = (await self.client.get_shopping_lists()).items except MealieAuthenticationError as error: - raise ConfigEntryError("Authentication failed") from error + raise ConfigEntryAuthFailed from error except MealieConnectionError as error: raise UpdateFailed(error) from error return self.shopping_lists @@ -137,7 +137,7 @@ class MealieShoppingListCoordinator( shopping_list_items[shopping_list_id] = shopping_items except MealieAuthenticationError as error: - raise ConfigEntryError("Authentication failed") from error + raise ConfigEntryAuthFailed from error except MealieConnectionError as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 2227882fc3a..be0689d416f 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -9,6 +9,12 @@ "data_description": { "host": "The URL of your Mealie instance." } + }, + "reauth_confirm": { + "description": "Please reauthenticate with Mealie.", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } } }, "error": { @@ -18,7 +24,9 @@ "mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry." }, "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%]", + "wrong_account": "You have to use the same account that was used to configure the integration." } }, "entity": { diff --git a/tests/components/mealie/test_config_flow.py b/tests/components/mealie/test_config_flow.py index c1159a4b51b..9320b028af8 100644 --- a/tests/components/mealie/test_config_flow.py +++ b/tests/components/mealie/test_config_flow.py @@ -6,11 +6,13 @@ from aiomealie import About, MealieAuthenticationError, MealieConnectionError import pytest from homeassistant.components.mealie.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry @@ -79,7 +81,6 @@ async def test_flow_errors( result["flow_id"], {CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY @@ -140,3 +141,105 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config_entry.entry_id}, + data=mock_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "token2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "token2" + + +async def test_reauth_flow_wrong_account( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with wrong account.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config_entry.entry_id}, + data=mock_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_mealie_client.get_user_info.return_value.user_id = "wrong_user_id" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "token2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MealieConnectionError, "cannot_connect"), + (MealieAuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow errors.""" + await setup_integration(hass, mock_config_entry) + mock_mealie_client.get_user_info.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config_entry.entry_id}, + data=mock_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_mealie_client.get_user_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful"