diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 682efde8881..841c9da3c70 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at from __future__ import annotations import asyncio -from typing import Any from aiohttp.web import Request, Response import voluptuous as vol @@ -17,12 +16,14 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.webhook import ( + async_generate_id, async_unregister as async_unregister_webhook, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_WEBHOOK_ID, Platform, ) @@ -39,6 +40,7 @@ from .common import ( get_data_manager_by_webhook_id, json_message_response, ) +from .const import CONF_USE_WEBHOOK, CONFIG DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -103,33 +105,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - config_updates: dict[str, Any] = {} - - # Add a unique id if it's an older config entry. - if entry.unique_id != entry.data["token"]["userid"] or not isinstance( - entry.unique_id, str - ): - config_updates["unique_id"] = str(entry.data["token"]["userid"]) - - # Add the webhook configuration. - if CONF_WEBHOOK_ID not in entry.data: - webhook_id = webhook.async_generate_id() - config_updates["data"] = { - **entry.data, - **{ - const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][ - const.CONF_USE_WEBHOOK - ], - CONF_WEBHOOK_ID: webhook_id, - }, + if CONF_USE_WEBHOOK not in entry.options: + new_data = entry.data.copy() + new_options = { + CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), } + unique_id = str(entry.data[CONF_TOKEN]["userid"]) + if CONF_WEBHOOK_ID not in new_data: + new_data[CONF_WEBHOOK_ID] = async_generate_id() - if config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, unique_id=unique_id + ) + use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] + if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: + new_options = entry.options.copy() + new_options |= {CONF_USE_WEBHOOK: use_webhook} + hass.config_entries.async_update_entry(entry, options=new_options) data_manager = await async_get_data_manager(hass, entry) - _LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile) + _LOGGER.debug("Confirming %s is authenticated to withings", entry.title) await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() webhook.async_register( diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 3d215567f45..98c98f1fa96 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -203,7 +203,6 @@ class DataManager: def __init__( self, hass: HomeAssistant, - profile: str, api: ConfigEntryWithingsApi, user_id: int, webhook_config: WebhookConfig, @@ -212,7 +211,6 @@ class DataManager: self._hass = hass self._api = api self._user_id = user_id - self._profile = profile self._webhook_config = webhook_config self._notify_subscribe_delay = SUBSCRIBE_DELAY self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY @@ -256,11 +254,6 @@ class DataManager: """Get the user_id of the authenticated user.""" return self._user_id - @property - def profile(self) -> str: - """Get the profile.""" - return self._profile - def async_start_polling_webhook_subscriptions(self) -> None: """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" self.async_stop_polling_webhook_subscriptions() @@ -530,12 +523,11 @@ async def async_get_data_manager( config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] if const.DATA_MANAGER not in config_entry_data: - profile: str = config_entry.data[const.PROFILE] - - _LOGGER.debug("Creating withings data manager for profile: %s", profile) + _LOGGER.debug( + "Creating withings data manager for profile: %s", config_entry.title + ) config_entry_data[const.DATA_MANAGER] = DataManager( hass, - profile, ConfigEntryWithingsApi( hass=hass, config_entry=config_entry, @@ -549,7 +541,7 @@ async def async_get_data_manager( url=webhook.async_generate_url( hass, config_entry.data[CONF_WEBHOOK_ID] ), - enabled=config_entry.data[const.CONF_USE_WEBHOOK], + enabled=config_entry.options[const.CONF_USE_WEBHOOK], ), ) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index b0fa1876d92..7bbf869069f 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,26 +5,24 @@ from collections.abc import Mapping import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.util import slugify -from . import const +from .const import CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN class WithingsFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): """Handle a config flow.""" - DOMAIN = const.DOMAIN + DOMAIN = DOMAIN - # Temporarily holds authorization data during the profile step. - _current_data: dict[str, None | str | int] = {} - _reauth_profile: str | None = None + reauth_entry: ConfigEntry | None = None @property def logger(self) -> logging.Logger: @@ -45,64 +43,37 @@ class WithingsFlowHandler( ) } - async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: - """Override the create entry so user can select a profile.""" - self._current_data = data - return await self.async_step_profile(data) - - async def async_step_profile(self, data: dict[str, Any]) -> FlowResult: - """Prompt the user to select a user profile.""" - errors = {} - profile = data.get(const.PROFILE) or self._reauth_profile - - if profile: - existing_entries = [ - config_entry - for config_entry in self._async_current_entries() - if slugify(config_entry.data.get(const.PROFILE)) == slugify(profile) - ] - - if self._reauth_profile or not existing_entries: - new_data = {**self._current_data, **data, const.PROFILE: profile} - self._current_data = {} - return await self.async_step_finish(new_data) - - errors["base"] = "already_configured" - - return self.async_show_form( - step_id="profile", - data_schema=vol.Schema({vol.Required(const.PROFILE): str}), - errors=errors, + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] ) - - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: - """Prompt user to re-authenticate.""" - self._reauth_profile = data.get(const.PROFILE) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, data: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Prompt user to re-authenticate.""" - if data is not None: - return await self.async_step_user() + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() - placeholders = {const.PROFILE: self._reauth_profile} + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + user_id = str(data[CONF_TOKEN]["userid"]) + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() - self.context.update({"title_placeholders": placeholders}) + return self.async_create_entry( + title=DEFAULT_TITLE, + data=data, + options={CONF_USE_WEBHOOK: False}, + ) - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders=placeholders, - ) + if self.reauth_entry.unique_id == user_id: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") - async def async_step_finish(self, data: dict[str, Any]) -> FlowResult: - """Finish the flow.""" - self._current_data = {} - - await self.async_set_unique_id( - str(data["token"]["userid"]), raise_on_progress=False - ) - self._abort_if_unique_id_configured(data) - - return self.async_create_entry(title=data[const.PROFILE], data=data) + return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 02d8977c604..926d29abe5c 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,6 +1,7 @@ """Constants used by the Withings component.""" from enum import StrEnum +DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index a1ad8828b81..f17d3ccf03c 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -46,8 +46,7 @@ class BaseWithingsSensor(Entity): ) self._state_data: Any | None = None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, - name=data_manager.profile, + identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" ) @property diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 94c7511054f..4634a77a8da 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -4,8 +4,10 @@ from typing import Any from urllib.parse import urlparse from homeassistant.components.webhook import async_generate_url +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -48,3 +50,16 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) ) await hass.config_entries.async_setup(config_entry.entry_id) + + +async def enable_webhooks(hass: HomeAssistant) -> None: + """Enable webhooks.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_USE_WEBHOOK: True, + } + }, + ) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index fdd076e2f43..a5e51c68c40 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -96,9 +96,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "scope": ",".join(scopes), }, "profile": TITLE, - "use_webhook": True, "webhook_id": WEBHOOK_ID, }, + options={ + "use_webhook": True, + }, ) diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 6629ba5730b..dca9fbc6437 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -6,7 +6,7 @@ from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import call_webhook, setup_integration +from . import call_webhook, enable_webhooks, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -21,6 +21,7 @@ async def test_binary_sensor( hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" + await enable_webhooks(hass) await setup_integration(hass, config_entry) client = await hass_client_no_auth() diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py deleted file mode 100644 index 80f5700d64c..00000000000 --- a/tests/components/withings/test_common.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for the Withings component.""" -from http import HTTPStatus -import re -from typing import Any -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import pytest -import requests_mock -from withings_api.common import NotifyAppli - -from homeassistant.components.withings.common import ConfigEntryWithingsApi -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation - -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - - -async def test_config_entry_withings_api(hass: HomeAssistant) -> None: - """Test ConfigEntryWithingsApi.""" - config_entry = MockConfigEntry( - data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}} - ) - config_entry.add_to_hass(hass) - - implementation_mock = MagicMock(spec=AbstractOAuth2Implementation) - implementation_mock.async_refresh_token.return_value = { - "expires_at": 1111111, - "access_token": "mock_access_token", - } - - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(".*"), - status_code=HTTPStatus.OK, - json={"status": 0, "body": {"message": "success"}}, - ) - - api = ConfigEntryWithingsApi(hass, config_entry, implementation_mock) - response = await hass.async_add_executor_job( - api.request, "test", {"arg1": "val1", "arg2": "val2"} - ) - assert response == {"message": "success"} - - -@pytest.mark.parametrize( - ("user_id", "arg_user_id", "arg_appli", "expected_code"), - [ - [0, 0, NotifyAppli.WEIGHT.value, 0], # Success - [0, None, 1, 0], # Success, we ignore the user_id. - [0, None, None, 12], # No request body. - [0, "GG", None, 20], # appli not provided. - [0, 0, None, 20], # appli not provided. - [0, 0, 99, 21], # Invalid appli. - [0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id - ], -) -async def test_webhook_post( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - user_id: int, - arg_user_id: Any, - arg_appli: Any, - expected_code: int, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", user_id) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - - post_data = {} - if arg_user_id is not None: - post_data["userid"] = arg_user_id - if arg_appli is not None: - post_data["appli"] = arg_appli - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, data=post_data - ) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - data = await resp.json() - resp.close() - - assert data["code"] == expected_code diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 360766e0286..768f6fed16d 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,13 +1,14 @@ """Tests for config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import DOMAIN, PROFILE -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.withings.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import CLIENT_ID +from . import setup_integration +from .conftest import CLIENT_ID, USER_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,18 +64,12 @@ async def test_full_flow( "homeassistant.components.withings.async_setup_entry", return_value=True ) as mock_setup: result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "profile" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk"} - ) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Henk" + assert result["title"] == "Withings" assert "result" in result assert result["result"].unique_id == "600" assert "token" in result["result"].data @@ -86,12 +81,13 @@ async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, + withings: AsyncMock, + config_entry: MockConfigEntry, disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={PROFILE: "Henk"}, unique_id="0") - config_entry.add_to_hass(hass) + await setup_integration(hass, config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -126,28 +122,13 @@ async def test_config_non_unique_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": 10, + "userid": USER_ID, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "profile" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk"} - ) - - assert result - assert result["errors"]["base"] == "already_configured" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk 2"} - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Henk 2" - assert "result" in result - assert result["result"].unique_id == "10" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_config_reauth_profile( @@ -155,18 +136,22 @@ async def test_config_reauth_profile( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, + withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: - """Test reauth an existing profile re-creates the config entry.""" - config_entry.add_to_hass(hass) + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, config_entry) - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - result = flows[0] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -198,12 +183,75 @@ async def test_config_reauth_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": "0", + "userid": USER_ID, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_config_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test reauth with wrong account.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 12346, + }, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index acd21886e78..4e7eb812f0a 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,31 +1,20 @@ """Tests for the Withings component.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock from urllib.parse import urlparse import pytest import voluptuous as vol -from withings_api.common import NotifyAppli, UnauthorizedException +from withings_api.common import NotifyAppli -import homeassistant.components.webhook as webhook from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const -from homeassistant.components.withings.common import ConfigEntryWithingsApi, DataManager -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.setup import async_setup_component +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import setup_integration -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from . import enable_webhooks, setup_integration from .conftest import WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -113,126 +102,6 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: hass.async_create_task.assert_not_called() -@pytest.mark.parametrize( - "exception", - [ - UnauthorizedException("401"), - UnauthorizedException("401"), - Exception("401, this is the message"), - ], -) -@patch("homeassistant.components.withings.common._RETRY_COEFFICIENT", 0) -async def test_auth_failure( - hass: HomeAssistant, - component_factory: ComponentFactory, - exception: Exception, - current_request_with_host: None, -) -> None: - """Test auth failure.""" - person0 = new_profile_config( - "person0", - 0, - api_response_user_get_device=exception, - api_response_measure_get_meas=exception, - api_response_sleep_get_summary=exception, - ) - - await component_factory.configure_component(profile_configs=(person0,)) - assert not hass.config_entries.flow.async_progress() - - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - await data_manager.poll_data_update_coordinator.async_refresh() - - flows = hass.config_entries.flow.async_progress() - assert flows - assert len(flows) == 1 - - flow = flows[0] - assert flow["handler"] == const.DOMAIN - - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result - assert result["type"] == "external" - assert result["handler"] == const.DOMAIN - assert result["step_id"] == "auth" - - await component_factory.unload(person0) - - -async def test_set_config_unique_id( - hass: HomeAssistant, component_factory: ComponentFactory -) -> None: - """Test upgrading configs to use a unique id.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": "my_user_id"}, - "auth_implementation": "withings", - "profile": person0.profile, - }, - ) - - with patch("homeassistant.components.withings.async_get_data_manager") as mock: - data_manager: DataManager = MagicMock(spec=DataManager) - data_manager.poll_data_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.poll_data_update_coordinator.last_update_success = True - data_manager.subscription_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.subscription_update_coordinator.last_update_success = True - mock.return_value = data_manager - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id == "my_user_id" - - -async def test_set_convert_unique_id_to_string(hass: HomeAssistant) -> None: - """Test upgrading configs to use a unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": 1234}, - "auth_implementation": "withings", - "profile": "person0", - }, - ) - config_entry.add_to_hass(hass) - - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, - }, - } - - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - spec=ConfigEntryWithingsApi, - ): - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, webhook.DOMAIN, hass_config) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() - - assert config_entry.unique_id == "1234" - - async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, @@ -241,6 +110,7 @@ async def test_data_manager_webhook_subscription( hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" + await enable_webhooks(hass) await setup_integration(hass, config_entry) await hass_client_no_auth() await hass.async_block_till_done() @@ -285,3 +155,87 @@ async def test_requests( path=urlparse(webhook_url).path, ) assert response.status == 200 + + +@pytest.mark.parametrize( + ("config_entry"), + [ + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + "webhook_id": "3290798afaebd28519c4883d3d411c7197572e0cc9b8d507471f59a700a61a55", + }, + ), + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + }, + ), + ], +) +async def test_config_flow_upgrade( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test config flow upgrade.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(config_entry.entry_id) + + assert entry.unique_id == "123" + assert entry.data["token"]["userid"] == 123 + assert CONF_WEBHOOK_ID in entry.data + assert entry.options == { + "use_webhook": False, + } + + +@pytest.mark.parametrize( + ("body", "expected_code"), + [ + [{"userid": 0, "appli": NotifyAppli.WEIGHT.value}, 0], # Success + [{"userid": None, "appli": 1}, 0], # Success, we ignore the user_id. + [{}, 12], # No request body. + [{"userid": "GG"}, 20], # appli not provided. + [{"userid": 0}, 20], # appli not provided. + [{"userid": 0, "appli": 99}, 21], # Invalid appli. + [ + {"userid": 11, "appli": NotifyAppli.WEIGHT.value}, + 0, + ], # Success, we ignore the user_id + ], +) +async def test_webhook_post( + hass: HomeAssistant, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + disable_webhook_delay, + body: dict[str, Any], + expected_code: int, + current_request_with_host: None, +) -> None: + """Test webhook callback.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + resp = await client.post(urlparse(webhook_url).path, data=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data = await resp.json() + resp.close() + + assert data["code"] == expected_code