Bump cookidoo-api to 0.18.4 and persist the OAuth2 tokens (#181382)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Federico Zivolo
2026-09-18 09:58:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 7e23c4b38e
commit 8777713f71
14 changed files with 678 additions and 98 deletions
+15 -3
View File
@@ -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
@@ -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,
@@ -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"
@@ -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",
@@ -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,
]
+34 -5
View File
@@ -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
@@ -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"]
}
+1 -1
View File
@@ -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
+152 -43
View File
@@ -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,
)
@@ -67,6 +67,7 @@
'email': 'test-email',
'language': 'de-CH',
'password': '**REDACTED**',
'token': '**REDACTED**',
}),
'user': dict({
'description': None,
+99 -8
View File
@@ -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)
+118 -10
View File
@@ -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
+162 -7
View File
@@ -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)
+33 -2
View File
@@ -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)