From 6c59c8037283e60d4e886265305b9491636d6a4e Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 27 May 2024 13:58:48 +0200 Subject: [PATCH 01/27] initial oauth2 implementation --- homeassistant/components/point/__init__.py | 156 ++++++-------- homeassistant/components/point/api.py | 26 +++ .../point/application_credentials.py | 14 ++ homeassistant/components/point/config_flow.py | 199 ++---------------- homeassistant/components/point/const.py | 3 + homeassistant/components/point/manifest.json | 2 +- homeassistant/components/point/strings.json | 33 ++- .../generated/application_credentials.py | 1 + 8 files changed, 143 insertions(+), 291 deletions(-) create mode 100644 homeassistant/components/point/api.py create mode 100644 homeassistant/components/point/application_credentials.py diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index e1536379084167..9fcb8748975916 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -3,24 +3,17 @@ import asyncio import logging -from httpx import ConnectTimeout from pypoint import PointSession -import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_TOKEN, - CONF_WEBHOOK_ID, - Platform, -) +from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -28,10 +21,9 @@ ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp -from . import config_flow +from . import api from .const import ( CONF_WEBHOOK_URL, DOMAIN, @@ -49,82 +41,49 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +type PointConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Minut Point component.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - config_flow.register_flow_implementation( - hass, DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET] - ) +async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: + """Set up Minut Point from a config entry.""" + hass.data.setdefault(DOMAIN, {}) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry ) ) - return True + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + entry.runtime_data = auth + _LOGGER.warning("FER, %s", await auth.async_get_access_token()) + pointSession = PointSession(auth) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Point from a config entry.""" + client = MinutPointClient(hass, entry, pointSession) + hass.async_create_task(client.update()) + hass.data[DOMAIN][entry.entry_id] = client - async def token_saver(token, **kwargs): - _LOGGER.debug("Saving updated token %s", token) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_TOKEN: token} - ) + hass.data[DOMAIN][DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() + hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP] = set() - session = PointSession( - async_get_clientsession(hass), - entry.data["refresh_args"][CONF_CLIENT_ID], - entry.data["refresh_args"][CONF_CLIENT_SECRET], - token=entry.data[CONF_TOKEN], - token_saver=token_saver, - ) - try: - # the call to user() implicitly calls ensure_active_token() in authlib - await session.user() - except ConnectTimeout as err: - _LOGGER.debug("Connection Timeout") - raise ConfigEntryNotReady from err - except Exception: # noqa: BLE001 - _LOGGER.error("Authentication Error") - return False - - hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() - hass.data[CONFIG_ENTRY_IS_SETUP] = set() - - await async_setup_webhook(hass, entry, session) - client = MinutPointClient(hass, entry, session) - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) - hass.async_create_task(client.update()) + await async_setup_webhook(hass, entry, pointSession) + # await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session): +async def async_setup_webhook( + hass: HomeAssistant, entry: PointConfigEntry, session: PointSession +) -> None: """Set up a webhook to handle binary sensor events.""" if CONF_WEBHOOK_ID not in entry.data: webhook_id = webhook.async_generate_id() webhook_url = webhook.async_generate_url(hass, webhook_id) - _LOGGER.info("Registering new webhook at: %s", webhook_url) + _LOGGER.warning("Registering new webhook at: %s", webhook_url) hass.config_entries.async_update_entry( entry, @@ -134,26 +93,34 @@ async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session): CONF_WEBHOOK_URL: webhook_url, }, ) - await session.update_webhook( + + if await session.update_webhook( entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID], ["*"], - ) - - webhook.async_register( - hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook - ) + ): + webhook.async_register( + hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + else: + _LOGGER.warning( + "Error registering webhook at: %s", entry.data[CONF_WEBHOOK_URL] + ) + data = {**entry.data} + data.pop(CONF_WEBHOOK_ID, None) + data.pop(CONF_WEBHOOK_URL, None) + hass.config_entries.async_update_entry( + entry, + data=data, + ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Unload a config entry.""" - webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - session = hass.data[DOMAIN].pop(entry.entry_id) - await session.remove_webhook() - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + session: PointSession = hass.data[DOMAIN].pop(entry) + await session.remove_webhook() return unload_ok @@ -203,12 +170,17 @@ async def _sync(self): async def new_device(device_id, platform): """Load new device.""" config_entries_key = f"{platform}.{DOMAIN}" - async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: - if config_entries_key not in self._hass.data[CONFIG_ENTRY_IS_SETUP]: + async with self._hass.data[DOMAIN][DATA_CONFIG_ENTRY_LOCK]: + if ( + config_entries_key + not in self._hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP] + ): await self._hass.config_entries.async_forward_entry_setup( self._config_entry, platform ) - self._hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) + self._hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP].add( + config_entries_key + ) async_dispatcher_send( self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id @@ -259,7 +231,7 @@ class MinutPointEntity(Entity): _attr_should_poll = False - def __init__(self, point_client, device_id, device_class): + def __init__(self, point_client, device_id, device_class) -> None: """Initialize the entity.""" self._async_unsub_dispatcher_connect = None self._client = point_client @@ -281,7 +253,7 @@ def __init__(self, point_client, device_id, device_class): if device_class: self._attr_name = f"{self._name} {device_class.capitalize()}" - def __str__(self): + def __str__(self) -> str: """Return string representation of device.""" return f"MinutPoint {self.name}" diff --git a/homeassistant/components/point/api.py b/homeassistant/components/point/api.py new file mode 100644 index 00000000000000..b55a7704cbf3ba --- /dev/null +++ b/homeassistant/components/point/api.py @@ -0,0 +1,26 @@ +"""API for Minut Point bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +import pypoint + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(pypoint.AbstractAuth): + """Provide Minut Point authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Minut Point auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/point/application_credentials.py b/homeassistant/components/point/application_credentials.py new file mode 100644 index 00000000000000..03cd02761f971b --- /dev/null +++ b/homeassistant/components/point/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Minut Point integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 279561b4e2b1b3..f3eb9cf85b5fa2 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -1,190 +1,31 @@ """Config flow for Minut Point.""" -import asyncio -from collections import OrderedDict import logging +from typing import Any -from pypoint import PointSession -import voluptuous as vol - -from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN -AUTH_CALLBACK_PATH = "/api/minut" -AUTH_CALLBACK_NAME = "api:minut" - -DATA_FLOW_IMPL = "point_flow_implementation" - -_LOGGER = logging.getLogger(__name__) - - -@callback -def register_flow_implementation(hass, domain, client_id, client_secret): - """Register a flow implementation. - - domain: Domain of the component responsible for the implementation. - name: Name of the component. - client_id: Client id. - client_secret: Client secret. - """ - if DATA_FLOW_IMPL not in hass.data: - hass.data[DATA_FLOW_IMPL] = OrderedDict() - - hass.data[DATA_FLOW_IMPL][domain] = { - CONF_CLIENT_ID: client_id, - CONF_CLIENT_SECRET: client_secret, - } - - -class PointFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize flow.""" - self.flow_impl = None - - async def async_step_import(self, user_input=None): - """Handle external yaml configuration.""" - if self._async_current_entries(): - return self.async_abort(reason="already_setup") - - self.flow_impl = DOMAIN - - return await self.async_step_auth() - - async def async_step_user(self, user_input=None): - """Handle a flow start.""" - flows = self.hass.data.get(DATA_FLOW_IMPL, {}) - - if self._async_current_entries(): - return self.async_abort(reason="already_setup") - - if not flows: - _LOGGER.debug("no flows") - return self.async_abort(reason="no_flows") - - if len(flows) == 1: - self.flow_impl = list(flows)[0] - return await self.async_step_auth() - - if user_input is not None: - self.flow_impl = user_input["flow_impl"] - return await self.async_step_auth() - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}), - ) - - async def async_step_auth(self, user_input=None): - """Create an entry for auth.""" - if self._async_current_entries(): - return self.async_abort(reason="external_setup") - - errors = {} - - if user_input is not None: - errors["base"] = "follow_link" - - try: - async with asyncio.timeout(10): - url = await self._get_authorization_url() - except TimeoutError: - return self.async_abort(reason="authorize_url_timeout") - except Exception: - _LOGGER.exception("Unexpected error generating auth url") - return self.async_abort(reason="unknown_authorize_url_generation") - return self.async_show_form( - step_id="auth", - description_placeholders={"authorization_url": url}, - errors=errors, - ) - - async def _get_authorization_url(self): - """Create Minut Point session and get authorization url.""" - flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] - client_id = flow[CONF_CLIENT_ID] - client_secret = flow[CONF_CLIENT_SECRET] - point_session = PointSession( - async_get_clientsession(self.hass), - client_id, - client_secret, - ) - - self.hass.http.register_view(MinutAuthCallbackView()) - - return point_session.get_authorization_url - - async def async_step_code(self, code=None): - """Received code for authentication.""" - if self._async_current_entries(): - return self.async_abort(reason="already_setup") - - if code is None: - return self.async_abort(reason="no_code") - - _LOGGER.debug( - "Should close all flows below %s", - self._async_in_progress(), - ) - # Remove notification if no other discovery config entries in progress - - return await self._async_create_session(code) - - async def _async_create_session(self, code): - """Create point session and entries.""" - - flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] - client_id = flow[CONF_CLIENT_ID] - client_secret = flow[CONF_CLIENT_SECRET] - point_session = PointSession( - async_get_clientsession(self.hass), - client_id, - client_secret, - ) - token = await point_session.get_access_token(code) - _LOGGER.debug("Got new token") - if not point_session.is_authorized: - _LOGGER.error("Authentication Error") - return self.async_abort(reason="auth_error") - - _LOGGER.info("Successfully authenticated Point") - user_email = (await point_session.user()).get("email") or "" - - return self.async_create_entry( - title=user_email, - data={ - "token": token, - "refresh_args": { - CONF_CLIENT_ID: client_id, - CONF_CLIENT_SECRET: client_secret, - }, - }, - ) +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Minut Point OAuth2 authentication.""" -class MinutAuthCallbackView(HomeAssistantView): - """Minut Authorization Callback View.""" + DOMAIN = DOMAIN - requires_auth = False - url = AUTH_CALLBACK_PATH - name = AUTH_CALLBACK_NAME + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - @staticmethod - async def get(request): - """Receive authorization code.""" - hass = request.app[KEY_HASS] - if "code" in request.query: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": "code"}, data=request.query["code"] - ) - ) - return "OK!" + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + existing_entry = await self.async_set_unique_id(DOMAIN) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title="Minut Point", data=data) diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index c8c8f14d019d85..a32d8ba36f84f6 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -12,3 +12,6 @@ SIGNAL_WEBHOOK = "point_webhook" POINT_DISCOVERY_NEW = "point_new_{}_{}" + +OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize" +OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token" diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 0e8d7068a4fae5..ec720c09b2b1e0 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -3,7 +3,7 @@ "name": "Minut Point", "codeowners": ["@fredrike"], "config_flow": true, - "dependencies": ["webhook", "http"], + "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/point", "iot_class": "cloud_polling", "loggers": ["pypoint"], diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index 8a28e314b69d72..5c0f0cad92a95b 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -1,29 +1,24 @@ { "config": { "step": { - "user": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", - "description": "[%key:common::config_flow::description::confirm_setup%]", - "data": { "flow_impl": "Provider" } - }, - "auth": { - "title": "Authenticate Point", - "description": "Please follow the link below and **Accept** access to your Minut account, then come back and press **Submit** below.\n\n[Link]({authorization_url})" + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - }, - "error": { - "no_token": "[%key:common::config_flow::error::invalid_access_token%]", - "follow_link": "Please follow the link and authenticate before pressing Submit" - }, "abort": { - "already_setup": "[%key:common::config_flow::abort::single_instance_allowed%]", - "external_setup": "Point successfully configured from another flow.", - "no_flows": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" } } } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index c576f242e30c64..4c4bc1d6e9e36e 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -22,6 +22,7 @@ "neato", "nest", "netatmo", + "point", "senz", "spotify", "twitch", From cfdf377b0c2a214e4c15d0d5639441332159ea28 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 28 May 2024 11:25:21 +0200 Subject: [PATCH 02/27] fix unload_entry --- homeassistant/components/point/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 9fcb8748975916..a1b2d482d79cf9 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -118,10 +118,10 @@ async def async_setup_webhook( async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - session: PointSession = hass.data[DOMAIN].pop(entry) - await session.remove_webhook() - + session: PointSession = hass.data[DOMAIN].pop(entry.entry_id) + if CONF_WEBHOOK_ID in entry.data: + webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await session.remove_webhook() return unload_ok From 059b1d18f3bfb485217a29f9c1a3dc9923a95369 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 28 May 2024 13:49:03 +0200 Subject: [PATCH 03/27] read old yaml/entry config --- homeassistant/components/point/__init__.py | 104 +++++++++++++++++- homeassistant/components/point/config_flow.py | 23 ++++ homeassistant/components/point/manifest.json | 2 +- homeassistant/components/point/strings.json | 22 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 133 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index a1b2d482d79cf9..b3d7a4ddf207cc 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,17 +1,32 @@ """Support for Minut Point.""" import asyncio +from http import HTTPStatus import logging +from aiohttp import ClientError, ClientResponseError from pypoint import PointSession +import voluptuous as vol from homeassistant.components import webhook -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_TOKEN, + CONF_WEBHOOK_ID, + Platform, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, + config_validation as cv, device_registry as dr, ) from homeassistant.helpers.device_registry import DeviceInfo @@ -21,6 +36,8 @@ ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp from . import api @@ -43,24 +60,100 @@ type PointConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] +CONF_REFRESH_TOKEN = "refresh_token" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Minut Point component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Point", + }, + ) + + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=conf, + ) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Set up Minut Point from a config entry.""" hass.data.setdefault(DOMAIN, {}) + if "auth_implementation" not in entry.data: + # Config entry is imported from old implementation without native oauth2. + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + "auth_implementation": DOMAIN, + CONF_TOKEN: { + **entry.data[CONF_TOKEN], + "expires_at": 0, + }, + "imported": True, + }, + ) + implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry ) ) - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), session ) entry.runtime_data = auth - _LOGGER.warning("FER, %s", await auth.async_get_access_token()) + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + pointSession = PointSession(auth) client = MinutPointClient(hass, entry, pointSession) @@ -71,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP] = set() await async_setup_webhook(hass, entry, pointSession) + # Entries are added in the client.update() function. # await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index f3eb9cf85b5fa2..765194ec8bb598 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Minut Point.""" +from collections.abc import Mapping import logging from typing import Any @@ -8,6 +9,10 @@ from .const import DOMAIN +CONF_REFRESH_TOKEN = "refresh_token" + +DATA_FLOW_IMPL = "point_flow_implementation" + class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN @@ -21,6 +26,24 @@ def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from YAML.""" + return await self.async_step_user() + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" existing_entry = await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index ec720c09b2b1e0..7b0a2f0e01e8c8 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pypoint"], "quality_scale": "silver", - "requirements": ["pypoint==2.3.2"] + "requirements": ["pypoint==3.0.0"] } diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index 5c0f0cad92a95b..b5b2ff02ed0235 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -1,24 +1,18 @@ { "config": { - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - } - }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Point integration needs to re-authenticate your account" + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index e946de503b3b0f..b6c70b5856a47a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2078,7 +2078,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.2 +pypoint==3.0.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5452bfa9de6f65..43050ef530da3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1632,7 +1632,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.2 +pypoint==3.0.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 From 2fb90fe565b38074a2a42e5bc78d550778f7f48a Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 29 May 2024 09:04:41 +0200 Subject: [PATCH 04/27] update tests --- .coveragerc | 1 + tests/components/point/__init__.py | 11 + tests/components/point/test_config_flow.py | 242 +++++++++++---------- 3 files changed, 135 insertions(+), 119 deletions(-) diff --git a/.coveragerc b/.coveragerc index d9772288ba27fe..add70fa1c7ca13 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1038,6 +1038,7 @@ omit = homeassistant/components/pocketcasts/sensor.py homeassistant/components/point/__init__.py homeassistant/components/point/alarm_control_panel.py + homeassistant/components/point/api.py homeassistant/components/point/binary_sensor.py homeassistant/components/point/sensor.py homeassistant/components/powerwall/__init__.py diff --git a/tests/components/point/__init__.py b/tests/components/point/__init__.py index 9fb6eea9ac70b0..254eef2e936d1e 100644 --- a/tests/components/point/__init__.py +++ b/tests/components/point/__init__.py @@ -1 +1,12 @@ """Tests for the Point component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index ec71b04b84bcd2..6c03baa69b8e27 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -1,151 +1,155 @@ -"""Tests for the Point config flow.""" +"""Test the Minut Point config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest -from homeassistant.components.point import DOMAIN, config_flow -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.point.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -def init_config_flow(hass, side_effect=None): - """Init a configuration flow.""" - config_flow.register_flow_implementation(hass, DOMAIN, "id", "secret") - flow = config_flow.PointFlowHandler() - flow._get_authorization_url = AsyncMock( - return_value="https://example.com", side_effect=side_effect - ) - flow.hass = hass - return flow - +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" -@pytest.fixture -def is_authorized(): - """Set PointSession authorized.""" - return True +REDIRECT_URL = "https://example.com/auth/external/callback" @pytest.fixture -def mock_pypoint(is_authorized): - """Mock pypoint.""" - with patch( - "homeassistant.components.point.config_flow.PointSession" - ) as PointSession: - PointSession.return_value.get_access_token = AsyncMock( - return_value={"access_token": "boo"} - ) - PointSession.return_value.is_authorized = is_authorized - PointSession.return_value.user = AsyncMock( - return_value={"email": "john.doe@example.com"} - ) - yield PointSession - - -async def test_abort_if_no_implementation_registered(hass: HomeAssistant) -> None: - """Test we abort if no implementation is registered.""" - flow = config_flow.PointFlowHandler() - flow.hass = hass - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_flows" - - -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: - """Test we abort if Point is already setup.""" - flow = init_config_flow(hass) - - with patch.object(hass.config_entries, "async_entries", return_value=[{}]): - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_setup" - - with patch.object(hass.config_entries, "async_entries", return_value=[{}]): - result = await flow.async_step_import() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_setup" - - -async def test_full_flow_implementation(hass: HomeAssistant, mock_pypoint) -> None: - """Test registering an implementation and finishing flow works.""" - config_flow.register_flow_implementation(hass, "test-other", None, None) - flow = init_config_flow(hass) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) - result = await flow.async_step_user() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - result = await flow.async_step_user({"flow_impl": "test"}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["description_placeholders"] == { - "authorization_url": "https://example.com" - } +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) - result = await flow.async_step_code("123ABC") - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["refresh_args"] == { - CONF_CLIENT_ID: "id", - CONF_CLIENT_SECRET: "secret", - } - assert result["title"] == "john.doe@example.com" - assert result["data"]["token"] == {"access_token": "boo"} + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + ) + 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.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) -async def test_step_import(hass: HomeAssistant, mock_pypoint) -> None: - """Test that we trigger import when configuring with client.""" - flow = init_config_flow(hass) + with patch( + "homeassistant.components.point.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) - result = await flow.async_step_import() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 -@pytest.mark.parametrize("is_authorized", [False]) -async def test_wrong_code_flow_implementation( - hass: HomeAssistant, mock_pypoint +async def test_reauthentication_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials, ) -> None: - """Test wrong code.""" - flow = init_config_flow(hass) - - result = await flow.async_step_code("123ABC") - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "auth_error" - - -async def test_not_pick_implementation_if_only_one(hass: HomeAssistant) -> None: - """Test we allow picking implementation if we have one flow_imp.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" + """Test reauthentication flow.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DOMAIN, + version=1, + data={"id": "timmo", "auth_implementation": DOMAIN}, + ) + old_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data + ) -async def test_abort_if_timeout_generating_auth_url(hass: HomeAssistant) -> None: - """Test we abort if generating authorize url fails.""" - flow = init_config_flow(hass, side_effect=TimeoutError) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "authorize_url_timeout" + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) -async def test_abort_if_exception_generating_auth_url(hass: HomeAssistant) -> None: - """Test we abort if generating authorize url blows up.""" - flow = init_config_flow(hass, side_effect=ValueError) + with ( + patch("homeassistant.components.point.api.AsyncConfigEntryAuth"), + patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ) as mock_setup, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - result = await flow.async_step_user() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown_authorize_url_generation" + assert result["reason"] == "reauth_successful" + assert len(mock_setup.mock_calls) == 1 -async def test_abort_no_code(hass: HomeAssistant) -> None: - """Test if no code is given to step_code.""" - flow = init_config_flow(hass) - result = await flow.async_step_code() +async def test_import_flow( + hass: HomeAssistant, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_code" + assert result["reason"] == "missing_credentials" From f0bf932bb0f6b2b8afea6a558a45d19f34c7b9ba Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Fri, 19 Jul 2024 11:36:28 +0200 Subject: [PATCH 05/27] fix: pylint on tests --- tests/components/point/test_config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 6c03baa69b8e27..6fc18a03dd297e 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -36,11 +36,11 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, setup_credentials, ) -> None: """Check full flow.""" @@ -85,11 +85,11 @@ async def test_full_flow( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, ) -> None: """Test reauthentication flow.""" From 31d313eea0d597d889328aa97d9e74895116581f Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Fri, 9 Aug 2024 21:39:31 +0200 Subject: [PATCH 06/27] Apply suggestions from code review Co-authored-by: Robert Resch --- homeassistant/components/point/__init__.py | 15 +++++++-------- homeassistant/components/point/config_flow.py | 3 +-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 0977a7edb023a2..cb3d5844721bf0 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -95,7 +95,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.8.0", + breaks_in_ha_version="2025.3.0", is_fixable=False, issue_domain=DOMAIN, severity=IssueSeverity.WARNING, @@ -106,11 +106,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=conf, - ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) return True @@ -165,7 +165,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo await async_setup_webhook(hass, entry, pointSession) # Entries are added in the client.update() function. - # await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -177,7 +176,7 @@ async def async_setup_webhook( if CONF_WEBHOOK_ID not in entry.data: webhook_id = webhook.async_generate_id() webhook_url = webhook.async_generate_url(hass, webhook_id) - _LOGGER.warning("Registering new webhook at: %s", webhook_url) + _LOGGER.debug("Registering new webhook at: %s", webhook_url) hass.config_entries.async_update_entry( entry, diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 765194ec8bb598..1d0365531449f5 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -46,8 +46,7 @@ async def async_step_reauth_confirm( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" - existing_entry = await self.async_set_unique_id(DOMAIN) - if existing_entry: + if existing_entry := await self.async_set_unique_id(DOMAIN): self.hass.config_entries.async_update_entry(existing_entry, data=data) await self.hass.config_entries.async_reload(existing_entry.entry_id) return self.async_abort(reason="reauth_successful") From 04f1ca344a76f5055de570066a7f4bc0b89e518c Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 14 Aug 2024 14:09:05 +0200 Subject: [PATCH 07/27] fix constants, formatting --- homeassistant/components/point/__init__.py | 10 ++++------ homeassistant/components/point/config_flow.py | 2 -- homeassistant/components/point/const.py | 1 + 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index cb3d5844721bf0..6a1f4bc1b8e795 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -60,8 +60,6 @@ type PointConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] -CONF_REFRESH_TOKEN = "refresh_token" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -107,10 +105,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) return True diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 1d0365531449f5..fe3dd8b48302e3 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -9,8 +9,6 @@ from .const import DOMAIN -CONF_REFRESH_TOKEN = "refresh_token" - DATA_FLOW_IMPL = "point_flow_implementation" diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index a32d8ba36f84f6..1c2720749e6766 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -7,6 +7,7 @@ SCAN_INTERVAL = timedelta(minutes=1) CONF_WEBHOOK_URL = "webhook_url" +CONF_REFRESH_TOKEN = "refresh_token" EVENT_RECEIVED = "point_webhook_received" SIGNAL_UPDATE_ENTITY = "point_update" SIGNAL_WEBHOOK = "point_webhook" From 7de24393222e895c969ecf6fca96d31ba01a0b06 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 14 Aug 2024 14:37:02 +0200 Subject: [PATCH 08/27] use runtime_data --- homeassistant/components/point/__init__.py | 16 ++++++++-------- .../components/point/alarm_control_panel.py | 9 +++++++-- homeassistant/components/point/binary_sensor.py | 9 +++++++-- homeassistant/components/point/const.py | 2 ++ homeassistant/components/point/sensor.py | 4 ++-- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 6a1f4bc1b8e795..b28e23475080f7 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -43,6 +43,7 @@ from . import api from .const import ( CONF_WEBHOOK_URL, + DATA_CONFIG_ENTRY_CLIENT, DOMAIN, EVENT_RECEIVED, POINT_DISCOVERY_NEW, @@ -156,10 +157,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo client = MinutPointClient(hass, entry, pointSession) hass.async_create_task(client.update()) - hass.data[DOMAIN][entry.entry_id] = client - - hass.data[DOMAIN][DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() - hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP] = set() + entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] = client + entry.runtime_data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() + entry.runtime_data[CONFIG_ENTRY_IS_SETUP] = set() await async_setup_webhook(hass, entry, pointSession) # Entries are added in the client.update() function. @@ -209,7 +209,7 @@ async def async_setup_webhook( async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - session: PointSession = hass.data[DOMAIN].pop(entry.entry_id) + session: PointSession = entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await session.remove_webhook() @@ -263,15 +263,15 @@ async def _sync(self): async def new_device(device_id, platform): """Load new device.""" config_entries_key = f"{platform}.{DOMAIN}" - async with self._hass.data[DOMAIN][DATA_CONFIG_ENTRY_LOCK]: + async with self._config_entry.runtime_data[DATA_CONFIG_ENTRY_LOCK]: if ( config_entries_key - not in self._hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP] + not in self._config_entry.runtime_data[CONFIG_ENTRY_IS_SETUP] ): await self._hass.config_entries.async_forward_entry_setup( self._config_entry, platform ) - self._hass.data[DOMAIN][CONFIG_ENTRY_IS_SETUP].add( + self._config_entry.runtime_data[CONFIG_ENTRY_IS_SETUP].add( config_entries_key ) diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 844d1eba553fc7..d013899045f620 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -22,7 +22,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinutPointClient -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from .const import ( + DATA_CONFIG_ENTRY_CLIENT, + DOMAIN as POINT_DOMAIN, + POINT_DISCOVERY_NEW, + SIGNAL_WEBHOOK, +) _LOGGER = logging.getLogger(__name__) @@ -43,7 +48,7 @@ async def async_setup_entry( async def async_discover_home(home_id): """Discover and add a discovered home.""" - client = hass.data[POINT_DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] async_add_entities([MinutPointAlarmControl(client, home_id)], True) async_dispatcher_connect( diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 7a698925db6848..70948f56cc2ecf 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -17,7 +17,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinutPointEntity -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from .const import ( + DATA_CONFIG_ENTRY_CLIENT, + DOMAIN as POINT_DOMAIN, + POINT_DISCOVERY_NEW, + SIGNAL_WEBHOOK, +) _LOGGER = logging.getLogger(__name__) @@ -49,7 +54,7 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = hass.data[POINT_DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] async_add_entities( ( MinutPointBinarySensor(client, device_id, device_name) diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index 1c2720749e6766..709d5d6ae71a2d 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -16,3 +16,5 @@ OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize" OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token" + +DATA_CONFIG_ENTRY_CLIENT = "client" diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index f648bb4daf9896..d772c623cf9a51 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -18,7 +18,7 @@ from homeassistant.util.dt import parse_datetime from . import MinutPointEntity -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from .const import DATA_CONFIG_ENTRY_CLIENT, DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,7 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = hass.data[POINT_DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] async_add_entities( [ MinutPointSensor(client, device_id, description) From 38d57511ff664fd5e0b0d130bd825f4e3e3a9f19 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 11 Sep 2024 21:23:40 +0200 Subject: [PATCH 09/27] Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --- homeassistant/components/point/__init__.py | 2 +- homeassistant/components/point/config_flow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index b28e23475080f7..b870ffc25817ef 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -94,7 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.3.0", + breaks_in_ha_version="2025.4.0", is_fixable=False, issue_domain=DOMAIN, severity=IssueSeverity.WARNING, diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index fe3dd8b48302e3..ce3612b18f36ed 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -13,7 +13,7 @@ class OAuth2FlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN + AbstractOAuth2FlowHandler, domain=DOMAIN ): """Config flow to handle Minut Point OAuth2 authentication.""" From 7f52656ceadc053564c0ad1c3847f6c06c1036c5 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 11 Sep 2024 22:09:43 +0200 Subject: [PATCH 10/27] fix missing import --- homeassistant/components/point/__init__.py | 21 +++++-------------- homeassistant/components/point/config_flow.py | 6 ++---- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index b870ffc25817ef..dad287da46f9f8 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -185,25 +185,14 @@ async def async_setup_webhook( }, ) - if await session.update_webhook( + await session.update_webhook( entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID], ["*"], - ): - webhook.async_register( - hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook - ) - else: - _LOGGER.warning( - "Error registering webhook at: %s", entry.data[CONF_WEBHOOK_URL] - ) - data = {**entry.data} - data.pop(CONF_WEBHOOK_ID, None) - data.pop(CONF_WEBHOOK_URL, None) - hass.config_entries.async_update_entry( - entry, - data=data, - ) + ) + webhook.async_register( + hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index ce3612b18f36ed..2540f01280665f 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -5,16 +5,14 @@ from typing import Any from homeassistant.config_entries import ConfigFlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import DOMAIN DATA_FLOW_IMPL = "point_flow_implementation" -class OAuth2FlowHandler( - AbstractOAuth2FlowHandler, domain=DOMAIN -): +class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Minut Point OAuth2 authentication.""" DOMAIN = DOMAIN From d50f5e0bbb29493f1635d556e68969fca73f9f4b Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 11 Sep 2024 22:29:08 +0200 Subject: [PATCH 11/27] adopt to PointData dataclass --- homeassistant/components/point/__init__.py | 31 ++++++------------- .../components/point/alarm_control_panel.py | 9 ++---- .../components/point/binary_sensor.py | 9 ++---- homeassistant/components/point/const.py | 13 +++++++- homeassistant/components/point/sensor.py | 4 +-- 5 files changed, 27 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index dad287da46f9f8..2f8664c4b29192 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,6 +1,5 @@ """Support for Minut Point.""" -import asyncio from http import HTTPStatus import logging @@ -43,20 +42,17 @@ from . import api from .const import ( CONF_WEBHOOK_URL, - DATA_CONFIG_ENTRY_CLIENT, DOMAIN, EVENT_RECEIVED, POINT_DISCOVERY_NEW, SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK, + PointData, ) _LOGGER = logging.getLogger(__name__) -DATA_CONFIG_ENTRY_LOCK = "point_config_entry_lock" -CONFIG_ENTRY_IS_SETUP = "point_config_entry_is_setup" - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] type PointConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] @@ -116,7 +112,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Set up Minut Point from a config entry.""" - hass.data.setdefault(DOMAIN, {}) if "auth_implementation" not in entry.data: # Config entry is imported from old implementation without native oauth2. @@ -142,7 +137,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo auth = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), session ) - entry.runtime_data = auth try: await auth.async_get_access_token() @@ -153,15 +147,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo except ClientError as err: raise ConfigEntryNotReady from err - pointSession = PointSession(auth) + point_session = PointSession(auth) - client = MinutPointClient(hass, entry, pointSession) + client = MinutPointClient(hass, entry, point_session) hass.async_create_task(client.update()) - entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] = client - entry.runtime_data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() - entry.runtime_data[CONFIG_ENTRY_IS_SETUP] = set() + entry.runtime_data = PointData(client) # type: ignore[assignment] - await async_setup_webhook(hass, entry, pointSession) + await async_setup_webhook(hass, entry, point_session) # Entries are added in the client.update() function. return True @@ -198,7 +190,7 @@ async def async_setup_webhook( async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - session: PointSession = entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] + session: PointSession = entry.runtime_data.client if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await session.remove_webhook() @@ -252,17 +244,12 @@ async def _sync(self): async def new_device(device_id, platform): """Load new device.""" config_entries_key = f"{platform}.{DOMAIN}" - async with self._config_entry.runtime_data[DATA_CONFIG_ENTRY_LOCK]: - if ( - config_entries_key - not in self._config_entry.runtime_data[CONFIG_ENTRY_IS_SETUP] - ): + async with self._config_entry.runtime_data.lock: + if config_entries_key not in self._config_entry.runtime_data.entries: await self._hass.config_entries.async_forward_entry_setup( self._config_entry, platform ) - self._config_entry.runtime_data[CONFIG_ENTRY_IS_SETUP].add( - config_entries_key - ) + self._config_entry.runtime_data.entries.add(config_entries_key) async_dispatcher_send( self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index d013899045f620..9c755667c8ebd9 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -22,12 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinutPointClient -from .const import ( - DATA_CONFIG_ENTRY_CLIENT, - DOMAIN as POINT_DOMAIN, - POINT_DISCOVERY_NEW, - SIGNAL_WEBHOOK, -) +from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -48,7 +43,7 @@ async def async_setup_entry( async def async_discover_home(home_id): """Discover and add a discovered home.""" - client = config_entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] + client = config_entry.runtime_data.client async_add_entities([MinutPointAlarmControl(client, home_id)], True) async_dispatcher_connect( diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 70948f56cc2ecf..85dbc122c97d2b 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -17,12 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinutPointEntity -from .const import ( - DATA_CONFIG_ENTRY_CLIENT, - DOMAIN as POINT_DOMAIN, - POINT_DISCOVERY_NEW, - SIGNAL_WEBHOOK, -) +from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -54,7 +49,7 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = config_entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] + client = config_entry.runtime_data.client async_add_entities( ( MinutPointBinarySensor(client, device_id, device_name) diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index 709d5d6ae71a2d..6a548a91780cb5 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -1,7 +1,11 @@ """Define constants for the Point component.""" +import asyncio +from dataclasses import dataclass, field from datetime import timedelta +from . import MinutPointClient + DOMAIN = "point" SCAN_INTERVAL = timedelta(minutes=1) @@ -17,4 +21,11 @@ OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize" OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token" -DATA_CONFIG_ENTRY_CLIENT = "client" + +@dataclass +class PointData: + """Point Data.""" + + client: MinutPointClient + entry_lock: asyncio.Lock = asyncio.Lock() + entries: set[str | None] = field(default_factory=set) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index d772c623cf9a51..90993b0cd86af0 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -18,7 +18,7 @@ from homeassistant.util.dt import parse_datetime from . import MinutPointEntity -from .const import DATA_CONFIG_ENTRY_CLIENT, DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,7 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = config_entry.runtime_data[DATA_CONFIG_ENTRY_CLIENT] + client = config_entry.runtime_data.client async_add_entities( [ MinutPointSensor(client, device_id, description) From 2fc1c63943d5ae5b4a07067dcfe675b84de54a0b Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 12 Sep 2024 07:12:08 +0200 Subject: [PATCH 12/27] fix typing --- homeassistant/components/point/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 2f8664c4b29192..edf8b59e00069e 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -55,7 +55,7 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type PointConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] +type PointConfigEntry = ConfigEntry[PointData] CONFIG_SCHEMA = vol.Schema( { @@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo client = MinutPointClient(hass, entry, point_session) hass.async_create_task(client.update()) - entry.runtime_data = PointData(client) # type: ignore[assignment] + entry.runtime_data = PointData(client) await async_setup_webhook(hass, entry, point_session) # Entries are added in the client.update() function. From fb36135baea196869db757c44374514ec785610f Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sat, 14 Sep 2024 06:58:07 +0200 Subject: [PATCH 13/27] add more strings (copied from weheat) --- homeassistant/components/point/strings.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index b5b2ff02ed0235..d3535d1fe8905c 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -2,6 +2,15 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -9,6 +18,9 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" }, "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Point integration needs to re-authenticate your account" From 8613918d0be03bada9ccd33b9b0032b6238b7f5d Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sat, 14 Sep 2024 07:16:37 +0200 Subject: [PATCH 14/27] move the PointData dataclass to avoid circular imports --- homeassistant/components/point/__init__.py | 12 +++++++++++- homeassistant/components/point/const.py | 13 ------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index edf8b59e00069e..9e6ddc45451793 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,5 +1,7 @@ """Support for Minut Point.""" +import asyncio +from dataclasses import dataclass, field from http import HTTPStatus import logging @@ -48,7 +50,6 @@ SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK, - PointData, ) _LOGGER = logging.getLogger(__name__) @@ -375,3 +376,12 @@ def is_updated(self): def last_update(self): """Return the last_update time for the device.""" return parse_datetime(self.device.last_update) + + +@dataclass +class PointData: + """Point Data.""" + + client: MinutPointClient + entry_lock: asyncio.Lock = asyncio.Lock() + entries: set[str | None] = field(default_factory=set) diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index 6a548a91780cb5..1c2720749e6766 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -1,11 +1,7 @@ """Define constants for the Point component.""" -import asyncio -from dataclasses import dataclass, field from datetime import timedelta -from . import MinutPointClient - DOMAIN = "point" SCAN_INTERVAL = timedelta(minutes=1) @@ -20,12 +16,3 @@ OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize" OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token" - - -@dataclass -class PointData: - """Point Data.""" - - client: MinutPointClient - entry_lock: asyncio.Lock = asyncio.Lock() - entries: set[str | None] = field(default_factory=set) From 596831b812b3d3d2391ebec45d8e785534e7cf2a Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sat, 14 Sep 2024 07:54:57 +0200 Subject: [PATCH 15/27] use configflow inspired by withings --- homeassistant/components/point/config_flow.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 2540f01280665f..f46ebed73e2d09 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -4,19 +4,21 @@ import logging from typing import Any -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.components.webhook import async_generate_id +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import DOMAIN -DATA_FLOW_IMPL = "point_flow_implementation" - class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Minut Point OAuth2 authentication.""" DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -30,6 +32,9 @@ async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -42,8 +47,19 @@ async def async_step_reauth_confirm( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" - if existing_entry := await self.async_set_unique_id(DOMAIN): - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - return self.async_create_entry(title="Minut Point", data=data) + 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() + + return self.async_create_entry( + title="Minut Point", + data={**data, CONF_WEBHOOK_ID: async_generate_id()}, + ) + + if self.reauth_entry.unique_id == user_id: + return self.async_update_reload_and_abort( + self.reauth_entry, data={**self.reauth_entry.data, **data} + ) + + return self.async_abort(reason="wrong_account") From c518b40f9bfc945bf6be2d9cba4054a5ea94d8ad Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 09:36:13 +0200 Subject: [PATCH 16/27] raise ConfigEntryAuthFailed --- homeassistant/components/point/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 8fcb742c910509..4e240a040da5e7 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -121,13 +121,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo data={ **entry.data, "auth_implementation": DOMAIN, - CONF_TOKEN: { - **entry.data[CONF_TOKEN], - "expires_at": 0, - }, + CONF_TOKEN: entry.data[CONF_TOKEN], "imported": True, }, ) + raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( From d510cb99a9f7b0d6ab0a494e67ceee91780bb05c Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 11:05:58 +0200 Subject: [PATCH 17/27] it is called entry_lock --- homeassistant/components/point/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 4e240a040da5e7..b2f34ae23979ca 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -243,7 +243,7 @@ async def _sync(self): async def new_device(device_id, platform): """Load new device.""" config_entries_key = f"{platform}.{DOMAIN}" - async with self._config_entry.runtime_data.lock: + async with self._config_entry.runtime_data.entry_lock: if config_entries_key not in self._config_entry.runtime_data.entries: await self._hass.config_entries.async_forward_entry_setup( self._config_entry, platform From e29dc23af8549f6ec31678db0922d2651e96c071 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 11:48:32 +0200 Subject: [PATCH 18/27] fix webhook issue --- homeassistant/components/point/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index b2f34ae23979ca..78d47d8f20ab7a 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -177,7 +177,7 @@ async def async_setup_webhook( ) await session.update_webhook( - entry.data[CONF_WEBHOOK_URL], + webhook.async_generate_url(hass, entry.data[CONF_WEBHOOK_ID]), entry.data[CONF_WEBHOOK_ID], ["*"], ) From 72ed8ac5064bcd956dc846e991bf7c583a0f9b9a Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 11:50:19 +0200 Subject: [PATCH 19/27] fix oauth_create_entry --- homeassistant/components/point/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index f46ebed73e2d09..aa6b1c45f01d7d 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -47,7 +47,7 @@ async def async_step_reauth_confirm( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" - user_id = str(data[CONF_TOKEN]["userid"]) + user_id = str(data[CONF_TOKEN]["user_id"]) if not self.reauth_entry: await self.async_set_unique_id(user_id) self._abort_if_unique_id_configured() @@ -62,4 +62,4 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self.reauth_entry, data={**self.reauth_entry.data, **data} ) - return self.async_abort(reason="wrong_account") + return self.async_abort(reason="missing_configuration") From f407cc9911ea74c0069fa2b40b7aae349497ce38 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 11:52:56 +0200 Subject: [PATCH 20/27] stop using async_forward_entry_setup --- homeassistant/components/point/__init__.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 78d47d8f20ab7a..f39b6375634c04 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,7 +1,7 @@ """Support for Minut Point.""" import asyncio -from dataclasses import dataclass, field +from dataclasses import dataclass from http import HTTPStatus import logging @@ -188,7 +188,9 @@ async def async_setup_webhook( async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] + ): session: PointSession = entry.runtime_data.client if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) @@ -242,14 +244,6 @@ async def _sync(self): async def new_device(device_id, platform): """Load new device.""" - config_entries_key = f"{platform}.{DOMAIN}" - async with self._config_entry.runtime_data.entry_lock: - if config_entries_key not in self._config_entry.runtime_data.entries: - await self._hass.config_entries.async_forward_entry_setup( - self._config_entry, platform - ) - self._config_entry.runtime_data.entries.add(config_entries_key) - async_dispatcher_send( self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id ) @@ -257,10 +251,16 @@ async def new_device(device_id, platform): self._is_available = True for home_id in self._client.homes: if home_id not in self._known_homes: + await self._hass.config_entries.async_forward_entry_setups( + self._config_entry, [Platform.ALARM_CONTROL_PANEL] + ) await new_device(home_id, "alarm_control_panel") self._known_homes.add(home_id) for device in self._client.devices: if device.device_id not in self._known_devices: + await self._hass.config_entries.async_forward_entry_setups( + self._config_entry, PLATFORMS + ) for platform in PLATFORMS: await new_device(device.device_id, platform) self._known_devices.add(device.device_id) @@ -382,4 +382,3 @@ class PointData: client: MinutPointClient entry_lock: asyncio.Lock = asyncio.Lock() - entries: set[str | None] = field(default_factory=set) From 26a0682daee0767061620bd2071be25c08e75ceb Mon Sep 17 00:00:00 2001 From: Joostlek Date: Thu, 19 Sep 2024 13:36:32 +0200 Subject: [PATCH 21/27] Fixup --- homeassistant/components/point/__init__.py | 38 +++----- homeassistant/components/point/config_flow.py | 2 +- tests/components/point/test_config_flow.py | 88 +++++++++++++++---- 3 files changed, 88 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index f39b6375634c04..ca764a3844e07c 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_TOKEN, CONF_WEBHOOK_ID, Platform, ) @@ -78,15 +77,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = config[DOMAIN] - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], - ), - ) - async_create_issue( hass, HOMEASSISTANT_DOMAIN, @@ -102,11 +92,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + if not hass.config_entries.async_entries(DOMAIN): + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) ) - ) return True @@ -115,16 +115,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo """Set up Minut Point from a config entry.""" if "auth_implementation" not in entry.data: - # Config entry is imported from old implementation without native oauth2. - hass.config_entries.async_update_entry( - entry, - data={ - **entry.data, - "auth_implementation": DOMAIN, - CONF_TOKEN: entry.data[CONF_TOKEN], - "imported": True, - }, - ) raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") implementation = ( diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index aa6b1c45f01d7d..6ff14014b6a2e5 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -62,4 +62,4 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self.reauth_entry, data={**self.reauth_entry.data, **data} ) - return self.async_abort(reason="missing_configuration") + return self.async_abort(reason="wrong_account") diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 6fc18a03dd297e..785952e55ef992 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -10,6 +10,7 @@ async_import_client_credential, ) from homeassistant.components.point.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -25,7 +26,7 @@ REDIRECT_URL = "https://example.com/auth/external/callback" -@pytest.fixture +@pytest.fixture(autouse=True) async def setup_credentials(hass: HomeAssistant) -> None: """Fixture to setup credentials.""" assert await async_setup_component(hass, "application_credentials", {}) @@ -41,7 +42,6 @@ async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -73,16 +73,25 @@ async def test_full_flow( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, + "user_id": "abcd", }, ) with patch( "homeassistant.components.point.async_setup_entry", return_value=True ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "abcd" + assert result["result"].data["token"]["user_id"] == "abcd" + assert result["result"].data["token"]["type"] == "Bearer" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].data["token"]["expires_in"] == 60 + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert "webhook_id" in result["result"].data @pytest.mark.usefixtures("current_request_with_host") @@ -90,25 +99,19 @@ async def test_reauthentication_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials, ) -> None: """Test reauthentication flow.""" old_entry = MockConfigEntry( domain=DOMAIN, - unique_id=DOMAIN, + unique_id="abcd", version=1, data={"id": "timmo", "auth_implementation": DOMAIN}, ) old_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data - ) - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 + result = await old_entry.start_reauth_flow(hass) - result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -127,6 +130,7 @@ async def test_reauthentication_flow( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, + "user_id": "abcd", }, ) @@ -144,12 +148,66 @@ async def test_reauthentication_flow( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_wrong_account_in_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcde", + version=1, + data={"id": "timmo", "auth_implementation": DOMAIN}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + 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": REDIRECT_URL, + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": "abcd", + }, + ) + + with ( + patch("homeassistant.components.point.api.AsyncConfigEntryAuth"), + patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + async def test_import_flow( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test import flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + DOMAIN, context={"source": SOURCE_IMPORT} ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "missing_credentials" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pick_implementation" From f07d34d7a5293723a092e23975b4c24c230f8a83 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 14:38:31 +0200 Subject: [PATCH 22/27] fix strings --- homeassistant/components/point/config_flow.py | 2 +- tests/components/point/test_config_flow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 6ff14014b6a2e5..aa6b1c45f01d7d 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -62,4 +62,4 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self.reauth_entry, data={**self.reauth_entry.data, **data} ) - return self.async_abort(reason="wrong_account") + return self.async_abort(reason="missing_configuration") diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 785952e55ef992..62b00517dd8be9 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -197,7 +197,7 @@ async def test_wrong_account_in_reauth( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "wrong_account" + assert result["reason"] == "missing_configuration" async def test_import_flow( From b3979a4bb8ce5905a26b4bd5d0000a6ec4b6eb3e Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 19:40:55 +0200 Subject: [PATCH 23/27] fix issue that old config might be without unique_id --- homeassistant/components/point/config_flow.py | 11 +++++++++-- homeassistant/components/point/strings.json | 3 ++- tests/components/point/test_config_flow.py | 6 ++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index aa6b1c45f01d7d..d4f86044979e1b 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -57,9 +57,16 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu data={**data, CONF_WEBHOOK_ID: async_generate_id()}, ) - if self.reauth_entry.unique_id == user_id: + if self.reauth_entry.unique_id is None: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + if ( + self.reauth_entry.unique_id is None + or self.reauth_entry.unique_id == user_id + ): + logging.info("user_id: %s", user_id) return self.async_update_reload_and_abort( self.reauth_entry, data={**self.reauth_entry.data, **data} ) - return self.async_abort(reason="missing_configuration") + return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index d3535d1fe8905c..b2e8d9309d9ecd 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -12,7 +12,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "You can only reauthenticate this account with the same user." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 62b00517dd8be9..8706ba607d77be 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -94,8 +94,10 @@ async def test_full_flow( assert "webhook_id" in result["result"].data +@pytest.mark.parametrize("unique_id", ["abcd", None]) @pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication_flow( + unique_id: str, hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, @@ -103,7 +105,7 @@ async def test_reauthentication_flow( """Test reauthentication flow.""" old_entry = MockConfigEntry( domain=DOMAIN, - unique_id="abcd", + unique_id=unique_id, version=1, data={"id": "timmo", "auth_implementation": DOMAIN}, ) @@ -197,7 +199,7 @@ async def test_wrong_account_in_reauth( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "missing_configuration" + assert result["reason"] == "wrong_account" async def test_import_flow( From 58a21ea0d8a29c25de205e117cfe73dca2cbec9b Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 19 Sep 2024 19:51:13 +0200 Subject: [PATCH 24/27] parametrize tests --- tests/components/point/test_config_flow.py | 71 +++++----------------- 1 file changed, 14 insertions(+), 57 deletions(-) diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 8706ba607d77be..4baaf15d4c6128 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -94,10 +94,19 @@ async def test_full_flow( assert "webhook_id" in result["result"].data -@pytest.mark.parametrize("unique_id", ["abcd", None]) +@pytest.mark.parametrize( + ("unique_id", "expected"), + [ + ("abcd", "reauth_successful"), + (None, "reauth_successful"), + ("abcde", "wrong_account"), + ], + ids=("correct-unique_id", "missing-unique_id", "wrong-unique_id-abort"), +) @pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication_flow( - unique_id: str, + unique_id: str | None, + expected: str, hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, @@ -145,61 +154,9 @@ async def test_reauthentication_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert len(mock_setup.mock_calls) == 1 - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_wrong_account_in_reauth( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test reauthentication flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde", - version=1, - data={"id": "timmo", "auth_implementation": DOMAIN}, - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - - 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": REDIRECT_URL, - }, - ) - client = await hass_client_no_auth() - await client.get(f"/auth/external/callback?code=abcd&state={state}") - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "user_id": "abcd", - }, - ) - - with ( - patch("homeassistant.components.point.api.AsyncConfigEntryAuth"), - patch( - f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True - ), - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "wrong_account" + assert result["reason"] == expected + if expected == "reauth_successful": + assert len(mock_setup.mock_calls) == 1 async def test_import_flow( From 6b7bd296d6fa2cd8b2583f743fdae2d896141013 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 20 Sep 2024 11:29:50 +0200 Subject: [PATCH 25/27] Update homeassistant/components/point/config_flow.py --- homeassistant/components/point/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index d4f86044979e1b..9071c6af2465ff 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -64,7 +64,7 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self.reauth_entry.unique_id is None or self.reauth_entry.unique_id == user_id ): - logging.info("user_id: %s", user_id) + logging.debug("user_id: %s", user_id) return self.async_update_reload_and_abort( self.reauth_entry, data={**self.reauth_entry.data, **data} ) From b9bcb933f84399a9c9ff25103b177959a91590fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 20 Sep 2024 11:29:55 +0200 Subject: [PATCH 26/27] Update tests/components/point/test_config_flow.py --- tests/components/point/test_config_flow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 4baaf15d4c6128..8e5e285444dabd 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -155,8 +155,6 @@ async def test_reauthentication_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected - if expected == "reauth_successful": - assert len(mock_setup.mock_calls) == 1 async def test_import_flow( From 0a8e7ef8a573f942ec4def0a467924112901e634 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Fri, 20 Sep 2024 11:32:11 +0200 Subject: [PATCH 27/27] Fix --- homeassistant/components/point/config_flow.py | 7 +++---- tests/components/point/test_config_flow.py | 16 +++++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 9071c6af2465ff..0e4f88ab5786f0 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -57,16 +57,15 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu data={**data, CONF_WEBHOOK_ID: async_generate_id()}, ) - if self.reauth_entry.unique_id is None: - await self.async_set_unique_id(user_id) - self._abort_if_unique_id_configured() if ( self.reauth_entry.unique_id is None or self.reauth_entry.unique_id == user_id ): logging.debug("user_id: %s", user_id) return self.async_update_reload_and_abort( - self.reauth_entry, data={**self.reauth_entry.data, **data} + self.reauth_entry, + data={**self.reauth_entry.data, **data}, + unique_id=user_id, ) return self.async_abort(reason="wrong_account") diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 8e5e285444dabd..bd1e3cfac293dc 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -95,21 +95,22 @@ async def test_full_flow( @pytest.mark.parametrize( - ("unique_id", "expected"), + ("unique_id", "expected", "expected_unique_id"), [ - ("abcd", "reauth_successful"), - (None, "reauth_successful"), - ("abcde", "wrong_account"), + ("abcd", "reauth_successful", "abcd"), + (None, "reauth_successful", "abcd"), + ("abcde", "wrong_account", "abcde"), ], ids=("correct-unique_id", "missing-unique_id", "wrong-unique_id-abort"), ) @pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication_flow( - unique_id: str | None, - expected: str, hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + unique_id: str | None, + expected: str, + expected_unique_id: str, ) -> None: """Test reauthentication flow.""" old_entry = MockConfigEntry( @@ -149,12 +150,13 @@ async def test_reauthentication_flow( patch("homeassistant.components.point.api.AsyncConfigEntryAuth"), patch( f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True - ) as mock_setup, + ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected + assert old_entry.unique_id == expected_unique_id async def test_import_flow(