diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index dfe0a3b7a2b6..aa24c264824b 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -2,7 +2,11 @@ import logging -from cookidoo_api import CookidooAuthException, CookidooRequestException +from cookidoo_api import ( + CookidooAuthException, + CookidooParseException, + CookidooRequestException, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -98,7 +102,11 @@ async def async_migrate_entry( try: await cookidoo.login() user_info = await cookidoo.get_user_info() - except (CookidooRequestException, CookidooAuthException) as e: + except ( + CookidooAuthException, + CookidooParseException, + CookidooRequestException, + ) as e: _LOGGER.error("Could not migrate config entry: %s", e) return False @@ -114,7 +122,11 @@ async def async_migrate_entry( try: await cookidoo.login() user_info = await cookidoo.get_user_info() - except (CookidooRequestException, CookidooAuthException) as e: + except ( + CookidooAuthException, + CookidooParseException, + CookidooRequestException, + ) as e: _LOGGER.error("Could not migrate config entry: %s", e) return False diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index 9d4903f7b26e..302a7e9591d5 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -4,11 +4,7 @@ from datetime import date, datetime, timedelta import logging from typing import override -from cookidoo_api import ( - CookidooAuthException, - CookidooException, - CookidooRequestException, -) +from cookidoo_api import CookidooAuthException, CookidooException from cookidoo_api.types import CookidooCalendarDayRecipe from homeassistant.components.calendar import CalendarEntity, CalendarEvent @@ -82,14 +78,14 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity): except CookidooAuthException: try: await self.coordinator.cookidoo.login() - except (CookidooAuthException, CookidooRequestException) as exc: + return await self.coordinator.cookidoo.get_recipes_in_calendar_week( + week_day + ) + except CookidooException as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="calendar_fetch_failed", ) from exc - return await self.coordinator.cookidoo.get_recipes_in_calendar_week( - week_day - ) except CookidooException as e: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 569d04810c40..373d34013c38 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -1,11 +1,14 @@ """Config flow for Cookidoo integration.""" from collections.abc import Mapping +from dataclasses import asdict import logging from typing import Any, override from cookidoo_api import ( + CookidooAuthData, CookidooAuthException, + CookidooParseException, CookidooRequestException, get_country_options, get_localization_options, @@ -18,7 +21,14 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) +from homeassistant.core import callback from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, @@ -61,6 +71,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): user_input: dict[str, Any] user_uuid: str + # A login whose token response carries no refresh token leaves the library + # with nothing to hand us, and the entry is then created without tokens + token: dict[str, Any] = {} async def async_step_reconfigure( self, user_input: dict[str, Any] @@ -119,7 +132,12 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): ): if self.source == SOURCE_USER: return self.async_create_entry( - title="Cookidoo", data={**self.user_input, **language_input} + title="Cookidoo", + data={ + **self.user_input, + **language_input, + CONF_TOKEN: self.token, + }, ) reconfigure_entry = self._get_reconfigure_entry() return self.async_update_reload_and_abort( @@ -128,6 +146,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): **reconfigure_entry.data, **self.user_input, **language_input, + CONF_TOKEN: self.token, }, ) @@ -160,7 +179,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(self.user_uuid) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input + reauth_entry, data_updates={**user_input, CONF_TOKEN: self.token} ) return self.async_show_form( step_id="reauth_confirm", @@ -200,6 +219,11 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): ), } + @callback + def _save_token(self, auth_data: CookidooAuthData) -> None: + """Keep the tokens the library hands us during the validation requests.""" + self.token = asdict(auth_data) + async def validate_input( self, user_input: dict[str, Any], @@ -222,14 +246,21 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): await get_localization_options(country=data_input[CONF_COUNTRY].lower()) )[0].language # Pick any language to test login - cookidoo = await cookidoo_from_config_data(self.hass, data_input) + # Only this attempt's tokens may reach the entry: a login that yields + # none leaves _save_token uncalled, and an earlier attempt may have + # stored a pair, for another account in a reauth + self.token = {} + cookidoo = await cookidoo_from_config_data( + self.hass, data_input, on_auth_data_update=self._save_token + ) try: await cookidoo.login() user_info = await cookidoo.get_user_info() self.user_uuid = user_info.id if language_input: await cookidoo.get_additional_items() - except CookidooRequestException: + except CookidooRequestException, CookidooParseException: + # login() scrapes the CIAM login page, so it can also fail to parse it errors["base"] = "cannot_connect" except CookidooAuthException: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index 7f1e947c584a..24e5f54413be 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -11,6 +11,7 @@ from cookidoo_api import ( CookidooAuthException, CookidooException, CookidooIngredientItem, + CookidooParseException, CookidooRequestException, CookidooSubscription, CookidooUserInfo, @@ -60,11 +61,20 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ) self.cookidoo = cookidoo + async def _async_login(self) -> CookidooUserInfo: + """Return the user info, reusing the persisted tokens while they are valid.""" + if self.cookidoo.auth_data is not None: + try: + return await self.cookidoo.get_user_info() + except CookidooAuthException: + _LOGGER.debug("Stored tokens are no longer valid, logging in again") + await self.cookidoo.login() + return await self.cookidoo.get_user_info() + @override async def _async_setup(self) -> None: try: - await self.cookidoo.login() - self.user = await self.cookidoo.get_user_info() + self.user = await self._async_login() except CookidooRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -78,6 +88,12 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): CONF_EMAIL: self.config_entry.data[CONF_EMAIL] }, ) from e + except CookidooParseException as e: + # login() scrapes the CIAM login page, so it can also fail to parse it + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e @override async def _async_update_data(self) -> CookidooData: @@ -99,7 +115,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): CONF_EMAIL: self.config_entry.data[CONF_EMAIL] }, ) from exc - except CookidooRequestException as exc: + except (CookidooRequestException, CookidooParseException) as exc: raise UpdateFailed( translation_domain=DOMAIN, translation_key="setup_request_exception", diff --git a/homeassistant/components/cookidoo/diagnostics.py b/homeassistant/components/cookidoo/diagnostics.py index f981317df196..d3d059ffe31b 100644 --- a/homeassistant/components/cookidoo/diagnostics.py +++ b/homeassistant/components/cookidoo/diagnostics.py @@ -4,13 +4,14 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant from .coordinator import CookidooConfigEntry TO_REDACT = [ CONF_PASSWORD, + CONF_TOKEN, ] diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py index da11bf0784a9..3bda8a3917d6 100644 --- a/homeassistant/components/cookidoo/helpers.py +++ b/homeassistant/components/cookidoo/helpers.py @@ -1,19 +1,34 @@ """Helpers for cookidoo.""" +from collections.abc import Callable +from dataclasses import asdict from typing import Any from aiohttp import CookieJar -from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options +from cookidoo_api import ( + Cookidoo, + CookidooAuthData, + CookidooConfig, + get_localization_options, +) -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import CookidooConfigEntry async def cookidoo_from_config_data( - hass: HomeAssistant, data: dict[str, Any] + hass: HomeAssistant, + data: dict[str, Any], + on_auth_data_update: Callable[[CookidooAuthData], None] | None = None, ) -> Cookidoo: """Build cookidoo from config data.""" localizations = await get_localization_options( @@ -28,6 +43,7 @@ async def cookidoo_from_config_data( password=data[CONF_PASSWORD], localization=localizations[0], ), + on_auth_data_update=on_auth_data_update, ) @@ -35,4 +51,17 @@ async def cookidoo_from_config_entry( hass: HomeAssistant, entry: CookidooConfigEntry ) -> Cookidoo: """Build cookidoo from config entry.""" - return await cookidoo_from_config_data(hass, dict(entry.data)) + + @callback + def save_auth_data(auth_data: CookidooAuthData) -> None: + """Store the tokens, so a restart does not need a new login.""" + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_TOKEN: asdict(auth_data)} + ) + + cookidoo = await cookidoo_from_config_data( + hass, dict(entry.data), on_auth_data_update=save_auth_data + ) + if token := entry.data.get(CONF_TOKEN): + cookidoo.apply_auth_data(CookidooAuthData(**token)) + return cookidoo diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 015b01c834eb..943559bf7db2 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.17.2"] + "requirements": ["cookidoo-api==0.18.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index e81c23aa7558..fcfad60f3b85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -809,7 +809,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.17.2 +cookidoo-api==0.18.4 # homeassistant.components.backup # homeassistant.components.utility_meter diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 952b31ab6ebd..a2493ce7c638 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -1,10 +1,13 @@ """Common fixtures for the Cookidoo tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import Callable, Generator +from dataclasses import asdict +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from cookidoo_api import ( CookidooAdditionalItem, + CookidooAuthData, CookidooIngredientItem, CookidooSubscription, CookidooUserInfo, @@ -13,7 +16,13 @@ from cookidoo_api.types import CookidooCalendarDay, CookidooCalendarDayRecipe import pytest from homeassistant.components.cookidoo.const import DOMAIN -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) from tests.common import MockConfigEntry, load_json_object_fixture @@ -24,6 +33,17 @@ LANGUAGE = "de-CH" TEST_UUID = "sub_uuid" +AUTH_DATA = CookidooAuthData( + access_token="test-access-token", + refresh_token="test-refresh-token", + expires_at=1762000000.0, +) +STALE_AUTH_DATA = CookidooAuthData( + access_token="stale-access-token", + refresh_token="stale-refresh-token", + expires_at=1761000000.0, +) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -34,52 +54,122 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture -def mock_cookidoo_client() -> Generator[AsyncMock]: - """Mock a Cookidoo client.""" +@pytest.fixture(name="mock_cookidoo") +def mock_cookidoo_class() -> Generator[MagicMock]: + """Mock the Cookidoo class the integration instantiates.""" with patch( "homeassistant.components.cookidoo.helpers.Cookidoo", autospec=True, ) as mock_client: - client = mock_client.return_value - client.login.return_value = None - client.get_ingredient_items.return_value = [ - CookidooIngredientItem(**item) - for item in load_json_object_fixture("ingredient_items.json", DOMAIN)[ - "data" - ] - ] - client.get_additional_items.return_value = [ - CookidooAdditionalItem(**item) - for item in load_json_object_fixture("additional_items.json", DOMAIN)[ - "data" - ] - ] - client.get_active_subscription.return_value = CookidooSubscription( - **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + yield mock_client + + +@pytest.fixture +def notify_auth_data_update( + mock_cookidoo: MagicMock, +) -> Callable[[CookidooAuthData | None], None]: + """Emulate the library notifying its consumer of new tokens. + + A token response without a refresh token leaves the library with nothing to + hand over, which is what passing None stands for. + """ + + def _notify(auth_data: CookidooAuthData | None) -> None: + if auth_data is None: + return + mock_cookidoo.return_value.auth_data = auth_data + mock_cookidoo.call_args.kwargs["on_auth_data_update"](auth_data) + + return _notify + + +@pytest.fixture +def login_success( + notify_auth_data_update: Callable[[CookidooAuthData | None], None], +) -> Callable[[], None]: + """Emulate a successful login: fresh tokens, handed to the consumer.""" + + def _login() -> None: + notify_auth_data_update(AUTH_DATA) + + return _login + + +@pytest.fixture +def mock_cookidoo_client( + mock_cookidoo: MagicMock, + login_success: Callable[[], None], +) -> AsyncMock: + """Mock a Cookidoo client.""" + client = mock_cookidoo.return_value + # No tokens until a login provides them or the consumer restores them + client.auth_data = None + client.login.side_effect = login_success + client.apply_auth_data.side_effect = lambda auth_data: setattr( + client, "auth_data", auth_data + ) + client.get_ingredient_items.return_value = [ + CookidooIngredientItem(**item) + for item in load_json_object_fixture("ingredient_items.json", DOMAIN)["data"] + ] + client.get_additional_items.return_value = [ + CookidooAdditionalItem(**item) + for item in load_json_object_fixture("additional_items.json", DOMAIN)["data"] + ] + client.get_active_subscription.return_value = CookidooSubscription( + **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + ) + client.get_user_info.return_value = CookidooUserInfo( + **load_json_object_fixture("user_info.json", DOMAIN)["data"] + ) + client.get_recipes_in_calendar_week.return_value = [ + CookidooCalendarDay( + id=day["id"], + title=day["title"], + recipes=[ + CookidooCalendarDayRecipe( + id=recipe["id"], + name=recipe["name"], + total_time=recipe["total_time"], + thumbnail=recipe["thumbnail"], + image=recipe["image"], + url=recipe["url"], + ) + for recipe in day["recipes"] + ], ) - client.get_user_info.return_value = CookidooUserInfo( - **load_json_object_fixture("user_info.json", DOMAIN)["data"] + for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"] + ] + return client + + +@pytest.fixture +def arrange_validation_tokens( + mock_cookidoo_client: AsyncMock, + notify_auth_data_update: Callable[[CookidooAuthData | None], None], +) -> Callable[[CookidooAuthData | None, CookidooAuthData | None], None]: + """Arrange the tokens the config flow validation requests hand over. + + The login hands over the first, and the additional items fetch that follows + it the second, which is how a request rotating the tokens mid-validation + presents itself. Either can be None, for a response without a token. + """ + + def _arrange( + login_tokens: CookidooAuthData | None, + rotated_tokens: CookidooAuthData | None, + ) -> None: + mock_cookidoo_client.login.side_effect = lambda: notify_auth_data_update( + login_tokens ) - client.get_recipes_in_calendar_week.return_value = [ - CookidooCalendarDay( - id=day["id"], - title=day["title"], - recipes=[ - CookidooCalendarDayRecipe( - id=recipe["id"], - name=recipe["name"], - total_time=recipe["total_time"], - thumbnail=recipe["thumbnail"], - image=recipe["image"], - url=recipe["url"], - ) - for recipe in day["recipes"] - ], - ) - for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"] - ] - yield client + + async def _get_additional_items(*args: Any, **kwargs: Any) -> list: + notify_auth_data_update(rotated_tokens) + return [] + + mock_cookidoo_client.get_additional_items.side_effect = _get_additional_items + + return _arrange @pytest.fixture(name="cookidoo_config_entry") @@ -98,3 +188,22 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", unique_id=TEST_UUID, ) + + +@pytest.fixture(name="cookidoo_config_entry_with_token") +def mock_cookidoo_config_entry_with_token() -> MockConfigEntry: + """Mock a cookidoo configuration entry holding persisted OAuth2 tokens.""" + return MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=3, + data={ + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + CONF_TOKEN: asdict(STALE_AUTH_DATA), + }, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, + ) diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr index fd2bfe3e082f..74b9caa01337 100644 --- a/tests/components/cookidoo/snapshots/test_diagnostics.ambr +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -67,6 +67,7 @@ 'email': 'test-email', 'language': 'de-CH', 'password': '**REDACTED**', + 'token': '**REDACTED**', }), 'user': dict({ 'description': None, diff --git a/tests/components/cookidoo/test_calendar.py b/tests/components/cookidoo/test_calendar.py index 89274e662fc7..5ca774927995 100644 --- a/tests/components/cookidoo/test_calendar.py +++ b/tests/components/cookidoo/test_calendar.py @@ -1,20 +1,27 @@ """Test for calendar platform of the Cookidoo integration.""" -from collections.abc import Generator -from datetime import UTC, datetime +from collections.abc import Callable, Generator +from dataclasses import asdict +from datetime import UTC, date, datetime from unittest.mock import AsyncMock, patch -from cookidoo_api import CookidooAuthException, CookidooRequestException +from cookidoo_api import ( + CookidooAuthData, + CookidooAuthException, + CookidooParseException, + CookidooRequestException, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration +from .conftest import AUTH_DATA from tests.common import MockConfigEntry, snapshot_platform @@ -88,8 +95,10 @@ async def test_get_events( @pytest.mark.parametrize( "login_exception", [ - CookidooAuthException(), - CookidooRequestException(), + pytest.param(CookidooAuthException(), id="auth"), + pytest.param(CookidooRequestException(), id="request"), + pytest.param(CookidooParseException(), id="parse"), + pytest.param(None, id="retry_fails_after_successful_login"), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -98,9 +107,13 @@ async def test_get_events_login_failure( cookidoo_config_entry: MockConfigEntry, mock_cookidoo_client: AsyncMock, entity_registry: er.EntityRegistry, - login_exception: Exception, + login_exception: Exception | None, ) -> None: - """Test calendar handles login failures gracefully during event fetch.""" + """Test calendar handles login failures gracefully during event fetch. + + With no login exception the login succeeds and the retried fetch fails + instead, which must be reported the same way. + """ with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]): await setup_integration(hass, cookidoo_config_entry) @@ -131,3 +144,81 @@ async def test_get_events_login_failure( blocking=True, return_response=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_events_relogin_persists_tokens( + hass: HomeAssistant, + cookidoo_config_entry_with_token: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test tokens of a calendar re-login are persisted on the config entry.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + entities = er.async_entries_for_config_entry( + entity_registry, cookidoo_config_entry_with_token.entry_id + ) + entity_id = entities[0].entity_id + + week_plan = mock_cookidoo_client.get_recipes_in_calendar_week.return_value + mock_cookidoo_client.get_recipes_in_calendar_week.side_effect = [ + CookidooAuthException(), + week_plan, + week_plan, + ] + + await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": datetime(2025, 3, 4, tzinfo=UTC), + "end_date_time": datetime(2025, 3, 6, tzinfo=UTC), + }, + target={"entity_id": entity_id}, + blocking=True, + return_response=True, + ) + + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_events_persists_rotated_tokens( + hass: HomeAssistant, + cookidoo_config_entry_with_token: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + notify_auth_data_update: Callable[[CookidooAuthData], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test tokens rotated during a plain calendar fetch are persisted.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + entities = er.async_entries_for_config_entry( + entity_registry, cookidoo_config_entry_with_token.entry_id + ) + entity_id = entities[0].entity_id + + # The library rotates the tokens while serving the fetch, without a login + week_plan = mock_cookidoo_client.get_recipes_in_calendar_week.return_value + + def _rotate(week_day: date) -> list: + notify_auth_data_update(AUTH_DATA) + return week_plan + + mock_cookidoo_client.get_recipes_in_calendar_week.side_effect = _rotate + + await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": datetime(2025, 3, 4, tzinfo=UTC), + "end_date_time": datetime(2025, 3, 6, tzinfo=UTC), + }, + target={"entity_id": entity_id}, + blocking=True, + return_response=True, + ) + + mock_cookidoo_client.login.assert_not_awaited() + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 7e1344224413..eedf78be141c 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -1,21 +1,32 @@ """Test the Cookidoo config flow.""" +from collections.abc import Callable +from dataclasses import asdict +from typing import Any from unittest.mock import AsyncMock +from cookidoo_api import CookidooAuthData from cookidoo_api.exceptions import ( CookidooAuthException, CookidooException, + CookidooParseException, CookidooRequestException, ) import pytest from homeassistant.components.cookidoo.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD +from .conftest import AUTH_DATA, COUNTRY, EMAIL, LANGUAGE, PASSWORD from .test_init import setup_integration from tests.common import MockConfigEntry @@ -30,11 +41,45 @@ MOCK_DATA_LANGUAGE_STEP = { CONF_LANGUAGE: LANGUAGE, } +MOCK_TOKEN = asdict(AUTH_DATA) +ROTATED_AUTH_DATA = CookidooAuthData( + access_token="rotated-access-token", + refresh_token="rotated-refresh-token", + expires_at=1763000000.0, +) + +@pytest.mark.parametrize( + ("login_tokens", "rotated_tokens", "expected_token"), + [ + pytest.param(AUTH_DATA, None, MOCK_TOKEN, id="tokens_from_the_login"), + pytest.param( + AUTH_DATA, + ROTATED_AUTH_DATA, + asdict(ROTATED_AUTH_DATA), + id="tokens_rotated_during_validation", + ), + pytest.param(None, None, {}, id="no_tokens_from_the_login"), + ], +) async def test_flow_user_success( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_cookidoo_client: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + arrange_validation_tokens: Callable[ + [CookidooAuthData | None, CookidooAuthData | None], None + ], + login_tokens: CookidooAuthData | None, + rotated_tokens: CookidooAuthData | None, + expected_token: dict[str, Any], ) -> None: - """Test we get the user flow and create entry with success.""" + """Test we get the user flow and create entry with success. + + The entry is created with whatever tokens the validation ended up holding: + the ones the login handed over, the ones a later request rotated them into, + or none at all. + """ + arrange_validation_tokens(login_tokens, rotated_tokens) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -57,14 +102,57 @@ async def test_flow_user_success( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Cookidoo" - assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert result["data"] == { + **MOCK_DATA_USER_STEP, + **MOCK_DATA_LANGUAGE_STEP, + CONF_TOKEN: expected_token, + } assert len(mock_setup_entry.mock_calls) == 1 +async def test_flow_reauth_drops_tokens_of_a_failed_attempt( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test a retried reauth does not persist the tokens of an earlier attempt. + + The first attempt logs in -- which hands us its tokens -- and only then + fails, and the retry logs in without any. Those tokens belong to the + credentials that were rejected, so they must not reach the entry. + """ + await setup_integration(hass, cookidoo_config_entry) + mock_cookidoo_client.reset_mock() + + result = await cookidoo_config_entry.start_reauth_flow(hass) + + user_info = mock_cookidoo_client.get_user_info.return_value + mock_cookidoo_client.get_user_info.side_effect = [ + CookidooRequestException(), + user_info, + ] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "wrong-email", CONF_PASSWORD: "wrong-password"}, + ) + assert result["errors"] == {"base": "cannot_connect"} + + # The retried login yields no tokens, so nothing overwrites the stale pair + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["reason"] == "reauth_successful" + assert cookidoo_config_entry.data[CONF_TOKEN] == {} + + @pytest.mark.parametrize( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooAuthException(), "invalid_auth"), (CookidooException(), "unknown"), (IndexError(), "unknown"), @@ -73,6 +161,7 @@ async def test_flow_user_success( async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, + login_success: Callable[[], None], raise_error: Exception, text_error: str, ) -> None: @@ -91,7 +180,7 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( assert result["errors"]["base"] == text_error # Recover - mock_cookidoo_client.login.side_effect = None + mock_cookidoo_client.login.side_effect = login_success result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_DATA_USER_STEP, @@ -108,13 +197,18 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == "Cookidoo" - assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert result["data"] == { + **MOCK_DATA_USER_STEP, + **MOCK_DATA_LANGUAGE_STEP, + CONF_TOKEN: MOCK_TOKEN, + } @pytest.mark.parametrize( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooAuthException(), "invalid_auth"), (CookidooException(), "unknown"), (IndexError(), "unknown"), @@ -158,7 +252,11 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_2( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == "Cookidoo" - assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert result["data"] == { + **MOCK_DATA_USER_STEP, + **MOCK_DATA_LANGUAGE_STEP, + CONF_TOKEN: MOCK_TOKEN, + } async def test_flow_user_init_data_already_configured( @@ -224,6 +322,7 @@ async def test_flow_reconfigure_success( CONF_PASSWORD: "new-password", CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -232,6 +331,7 @@ async def test_flow_reconfigure_success( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooException(), "unknown"), (IndexError(), "unknown"), ], @@ -240,6 +340,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( hass: HomeAssistant, cookidoo_config_entry: AsyncMock, mock_cookidoo_client: AsyncMock, + login_success: Callable[[], None], raise_error: Exception, text_error: str, ) -> None: @@ -263,7 +364,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( assert result["errors"]["base"] == text_error # Recover - mock_cookidoo_client.login.side_effect = None + mock_cookidoo_client.login.side_effect = login_success result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, @@ -283,6 +384,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( **MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -291,6 +393,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooException(), "unknown"), (IndexError(), "unknown"), ], @@ -343,6 +446,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( **MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -401,6 +505,7 @@ async def test_flow_reauth( CONF_PASSWORD: "new-password", CONF_COUNTRY: COUNTRY, CONF_LANGUAGE: LANGUAGE, + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -409,6 +514,7 @@ async def test_flow_reauth( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooAuthException(), "invalid_auth"), (CookidooException(), "unknown"), (IndexError(), "unknown"), @@ -418,6 +524,7 @@ async def test_flow_reauth_error_and_recover( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, cookidoo_config_entry: MockConfigEntry, + login_success: Callable[[], None], raise_error, text_error, ) -> None: @@ -438,7 +545,7 @@ async def test_flow_reauth_error_and_recover( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - mock_cookidoo_client.login.side_effect = None + mock_cookidoo_client.login.side_effect = login_success result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, @@ -451,6 +558,7 @@ async def test_flow_reauth_error_and_recover( CONF_PASSWORD: "new-password", CONF_COUNTRY: COUNTRY, CONF_LANGUAGE: LANGUAGE, + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py index 8de8932c2f2e..afca9819e84d 100644 --- a/tests/components/cookidoo/test_init.py +++ b/tests/components/cookidoo/test_init.py @@ -1,8 +1,17 @@ """Unit tests for the cookidoo integration.""" +from collections.abc import Callable +from dataclasses import asdict +from datetime import timedelta from unittest.mock import AsyncMock -from cookidoo_api import CookidooAuthException, CookidooRequestException +from cookidoo_api import ( + CookidooAuthData, + CookidooAuthException, + CookidooParseException, + CookidooRequestException, +) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.cookidoo.const import DOMAIN @@ -12,15 +21,24 @@ from homeassistant.const import ( CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD, + CONF_TOKEN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration -from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID +from .conftest import ( + AUTH_DATA, + COUNTRY, + EMAIL, + LANGUAGE, + PASSWORD, + STALE_AUTH_DATA, + TEST_UUID, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_cookidoo_client") @@ -45,6 +63,7 @@ async def test_load_unload( [ (CookidooRequestException, ConfigEntryState.SETUP_RETRY), (CookidooAuthException, ConfigEntryState.SETUP_ERROR), + (CookidooParseException, ConfigEntryState.SETUP_RETRY), ], ) async def test_init_failure( @@ -85,11 +104,27 @@ async def test_config_entry_not_ready( @pytest.mark.parametrize( - ("login_exception", "status"), + ("login_exception", "status", "reason"), [ - (None, ConfigEntryState.LOADED), - (CookidooRequestException(), ConfigEntryState.SETUP_RETRY), - (CookidooAuthException(), ConfigEntryState.SETUP_ERROR), + pytest.param(None, ConfigEntryState.LOADED, None, id="relogin_succeeds"), + pytest.param( + CookidooRequestException(), + ConfigEntryState.SETUP_RETRY, + "Failed to connect to server, try again later", + id="request", + ), + pytest.param( + CookidooAuthException(), + ConfigEntryState.SETUP_ERROR, + "Authentication failed for test-email, check your email and password", + id="auth", + ), + pytest.param( + CookidooParseException(), + ConfigEntryState.SETUP_RETRY, + "Failed to connect to server, try again later", + id="parse", + ), ], ) async def test_config_entry_not_ready_auth_error( @@ -98,6 +133,7 @@ async def test_config_entry_not_ready_auth_error( mock_cookidoo_client: AsyncMock, login_exception: Exception | None, status: ConfigEntryState, + reason: str | None, ) -> None: """Test config entry recovery when data fetch hits an auth error. @@ -121,6 +157,8 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert cookidoo_config_entry.state is status + # A translated reason proves the exception was handled rather than escaping + assert cookidoo_config_entry.reason == reason MOCK_CONFIG_ENTRY_MIGRATION = { @@ -344,6 +382,20 @@ async def test_migration_from_partial_duplicate_unique_ids( "old_ciam_sub_uuid", CookidooAuthException, ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooParseException, + ), + ( + 1, + 2, + MOCK_CONFIG_ENTRY_MIGRATION, + "old_ciam_sub_uuid", + CookidooParseException, + ), ], ) async def test_migration_from_with_error( @@ -356,6 +408,7 @@ async def test_migration_from_with_error( unique_id, login_exception: Exception, mock_cookidoo_client: AsyncMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test different expected migration paths but with connection issues.""" # Migration can fail due to connection issues as we have to fetch the uuid @@ -406,6 +459,8 @@ async def test_migration_from_with_error( await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + # A handled failure, rather than the exception escaping async_migrate_entry + assert "Could not migrate config entry" in caplog.text assert entity_registry.async_is_registered( entity_registry.entities.get_entity_id( @@ -434,3 +489,103 @@ async def test_migration_from_with_error( ) ) ) + + +async def test_login_persists_tokens( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test the OAuth2 tokens of a credential login are stored on the entry.""" + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + mock_cookidoo_client.login.assert_awaited_once() + assert cookidoo_config_entry.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +async def test_tokens_persisted_when_user_info_fails( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test tokens of a successful login survive a failing user info fetch.""" + mock_cookidoo_client.get_user_info.side_effect = CookidooRequestException() + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.SETUP_RETRY + # Without this the next attempt would replay the whole login + assert cookidoo_config_entry.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +async def test_stored_tokens_skip_login( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry_with_token: MockConfigEntry, +) -> None: + """Test the persisted OAuth2 tokens are reused instead of logging in again.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + assert cookidoo_config_entry_with_token.state is ConfigEntryState.LOADED + mock_cookidoo_client.apply_auth_data.assert_called_once_with(STALE_AUTH_DATA) + mock_cookidoo_client.login.assert_not_awaited() + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(STALE_AUTH_DATA) + + +async def test_expired_tokens_fall_back_to_login( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry_with_token: MockConfigEntry, +) -> None: + """Test expired persisted tokens fall back to a credential login.""" + user_info = mock_cookidoo_client.get_user_info.return_value + mock_cookidoo_client.get_user_info.side_effect = [ + CookidooAuthException(), + user_info, + ] + + await setup_integration(hass, cookidoo_config_entry_with_token) + + assert cookidoo_config_entry_with_token.state is ConfigEntryState.LOADED + mock_cookidoo_client.login.assert_awaited_once() + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +@pytest.mark.parametrize( + "subscription_side_effect", + [ + pytest.param(None, id="update_succeeds"), + pytest.param(CookidooRequestException(), id="later_call_fails"), + ], +) +async def test_tokens_rotated_during_update_are_persisted( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry_with_token: MockConfigEntry, + notify_auth_data_update: Callable[[CookidooAuthData], None], + subscription_side_effect: Exception | None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test tokens the library rotates while serving an update are persisted. + + The refresh a request performs on its own rotates the refresh token with it, + so the new pair has to reach the entry whether the update as a whole went on + to succeed or a later call failed. + """ + await setup_integration(hass, cookidoo_config_entry_with_token) + + ingredient_items = mock_cookidoo_client.get_ingredient_items.return_value + + def _rotate() -> list: + notify_auth_data_update(AUTH_DATA) + return ingredient_items + + mock_cookidoo_client.get_ingredient_items.side_effect = _rotate + mock_cookidoo_client.get_active_subscription.side_effect = subscription_side_effect + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) diff --git a/tests/components/cookidoo/test_todo.py b/tests/components/cookidoo/test_todo.py index d66c4f357c26..7fd3be0bc5bd 100644 --- a/tests/components/cookidoo/test_todo.py +++ b/tests/components/cookidoo/test_todo.py @@ -1,11 +1,13 @@ """Test for todo platform of the Cookidoo integration.""" -from collections.abc import Generator +from collections.abc import Callable, Generator +from dataclasses import asdict import re from unittest.mock import AsyncMock, patch from cookidoo_api import ( CookidooAdditionalItem, + CookidooAuthData, CookidooIngredientItem, CookidooRequestException, ) @@ -21,12 +23,13 @@ from homeassistant.components.todo import ( TodoServices, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration +from .conftest import AUTH_DATA from tests.common import MockConfigEntry, snapshot_platform @@ -291,3 +294,31 @@ async def test_delete_additional_items_exception( target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, blocking=True, ) + + +async def test_failed_action_persists_rotated_tokens( + hass: HomeAssistant, + cookidoo_config_entry_with_token: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + notify_auth_data_update: Callable[[CookidooAuthData], None], +) -> None: + """Test tokens rotated during a failing todo action are still persisted.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + # The library rotates the tokens while serving the request, which then fails + def _rotate_then_fail(uids: list[str]) -> None: + notify_auth_data_update(AUTH_DATA) + raise CookidooRequestException + + mock_cookidoo_client.remove_additional_items.side_effect = _rotate_then_fail + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + service_data={ATTR_ITEM: "unique_id_tomaten"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA)