Add reauth flow to Mealie (#121697)

This commit is contained in:
Joost Lekkerkerker 2024-07-11 00:24:48 +02:00 committed by GitHub
parent 90af40b5c4
commit 385576bfb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 205 additions and 37 deletions

View File

@ -6,7 +6,11 @@ from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionE
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, Platform from homeassistant.const import CONF_API_TOKEN, CONF_HOST, Platform
from homeassistant.core import HomeAssistant 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 import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType 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() about = await client.get_about()
version = create_version(about.version) version = create_version(about.version)
except MealieAuthenticationError as error: except MealieAuthenticationError as error:
raise ConfigEntryError("Authentication failed") from error raise ConfigEntryAuthFailed from error
except MealieConnectionError as error: except MealieConnectionError as error:
raise ConfigEntryNotReady(error) from error raise ConfigEntryNotReady(error) from error

View File

@ -1,62 +1,115 @@
"""Config flow for Mealie.""" """Config flow for Mealie."""
from collections.abc import Mapping
from typing import Any from typing import Any
from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError
import voluptuous as vol 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.const import CONF_API_TOKEN, CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION
from .utils import create_version from .utils import create_version
SCHEMA = vol.Schema( USER_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST): str,
vol.Required(CONF_API_TOKEN): str, vol.Required(CONF_API_TOKEN): str,
} }
) )
REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
class MealieConfigFlow(ConfigFlow, domain=DOMAIN): class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
"""Mealie config flow.""" """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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input: if user_input:
client = MealieClient( self.host = user_input[CONF_HOST]
user_input[CONF_HOST], errors, user_id = await self.check_connection(
token=user_input[CONF_API_TOKEN], user_input[CONF_API_TOKEN],
session=async_get_clientsession(self.hass),
) )
try: if not errors:
info = await client.get_user_info() await self.async_set_unique_id(user_id)
about = await client.get_about() self._abort_if_unique_id_configured()
version = create_version(about.version) return self.async_create_entry(
except MealieConnectionError: title="Mealie",
errors["base"] = "cannot_connect" data=user_input,
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,
)
return self.async_show_form( return self.async_show_form(
step_id="user", 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, errors=errors,
) )

View File

@ -17,7 +17,7 @@ from aiomealie import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util 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()) await self.client.get_mealplans(dt_util.now().date(), next_week.date())
).items ).items
except MealieAuthenticationError as error: except MealieAuthenticationError as error:
raise ConfigEntryError("Authentication failed") from error raise ConfigEntryAuthFailed from error
except MealieConnectionError as error: except MealieConnectionError as error:
raise UpdateFailed(error) from error raise UpdateFailed(error) from error
res: dict[MealplanEntryType, list[Mealplan]] = { res: dict[MealplanEntryType, list[Mealplan]] = {
@ -116,7 +116,7 @@ class MealieShoppingListCoordinator(
try: try:
self.shopping_lists = (await self.client.get_shopping_lists()).items self.shopping_lists = (await self.client.get_shopping_lists()).items
except MealieAuthenticationError as error: except MealieAuthenticationError as error:
raise ConfigEntryError("Authentication failed") from error raise ConfigEntryAuthFailed from error
except MealieConnectionError as error: except MealieConnectionError as error:
raise UpdateFailed(error) from error raise UpdateFailed(error) from error
return self.shopping_lists return self.shopping_lists
@ -137,7 +137,7 @@ class MealieShoppingListCoordinator(
shopping_list_items[shopping_list_id] = shopping_items shopping_list_items[shopping_list_id] = shopping_items
except MealieAuthenticationError as error: except MealieAuthenticationError as error:
raise ConfigEntryError("Authentication failed") from error raise ConfigEntryAuthFailed from error
except MealieConnectionError as error: except MealieConnectionError as error:
raise UpdateFailed(error) from error raise UpdateFailed(error) from error

View File

@ -9,6 +9,12 @@
"data_description": { "data_description": {
"host": "The URL of your Mealie instance." "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": { "error": {
@ -18,7 +24,9 @@
"mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry." "mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry."
}, },
"abort": { "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": { "entity": {

View File

@ -6,11 +6,13 @@ from aiomealie import About, MealieAuthenticationError, MealieConnectionError
import pytest import pytest
from homeassistant.components.mealie.const import DOMAIN 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.const import CONF_API_TOKEN, CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -79,7 +81,6 @@ async def test_flow_errors(
result["flow_id"], result["flow_id"],
{CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, {CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"},
) )
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
@ -140,3 +141,105 @@ async def test_duplicate(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" 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"