Add support for native oauth2 in Point (#118243)

* initial oauth2 implementation

* fix unload_entry

* read old yaml/entry config

* update tests

* fix: pylint on tests

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* fix constants, formatting

* use runtime_data

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix missing import

* adopt to PointData dataclass

* fix typing

* add more strings (copied from weheat)

* move the PointData dataclass to avoid circular imports

* use configflow inspired by withings

* raise ConfigEntryAuthFailed

* it is called entry_lock

* fix webhook issue

* fix oauth_create_entry

* stop using async_forward_entry_setup

* Fixup

* fix strings

* fix issue that old config might be without unique_id

* parametrize tests

* Update homeassistant/components/point/config_flow.py

* Update tests/components/point/test_config_flow.py

* Fix

---------

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
pull/125763/head
Fredrik Erlandsson 2024-09-20 12:02:07 +02:00 committed by GitHub
parent 7a9da6dde1
commit 1768daf98c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 393 additions and 407 deletions

View File

@ -1,27 +1,34 @@
"""Support for Minut Point.""" """Support for Minut Point."""
import asyncio import asyncio
from dataclasses import dataclass
from http import HTTPStatus
import logging import logging
from aiohttp import web from aiohttp import ClientError, ClientResponseError, web
from httpx import ConnectTimeout
from pypoint import PointSession from pypoint import PointSession
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import webhook from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_CLIENT_ID, CONF_CLIENT_ID,
CONF_CLIENT_SECRET, CONF_CLIENT_SECRET,
CONF_TOKEN,
CONF_WEBHOOK_ID, CONF_WEBHOOK_ID,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import (
from homeassistant.helpers.aiohttp_client import async_get_clientsession aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
device_registry as dr,
)
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
@ -29,10 +36,11 @@ from homeassistant.helpers.dispatcher import (
) )
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval 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.helpers.typing import ConfigType
from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp
from . import config_flow from . import api
from .const import ( from .const import (
CONF_WEBHOOK_URL, CONF_WEBHOOK_URL,
DOMAIN, DOMAIN,
@ -45,11 +53,10 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _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] PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
type PointConfigEntry = ConfigEntry[PointData]
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
@ -70,57 +77,80 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
conf = config[DOMAIN] conf = config[DOMAIN]
config_flow.register_flow_implementation( async_create_issue(
hass, DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET] hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Point",
},
)
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.async_create_task(
hass.config_entries.flow.async_init( hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT} DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
) )
) )
return True return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Set up Point from a config entry.""" """Set up Minut Point from a config entry."""
async def token_saver(token, **kwargs): if "auth_implementation" not in entry.data:
_LOGGER.debug("Saving updated token %s", token) raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_TOKEN: token} 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
) )
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: try:
# the call to user() implicitly calls ensure_active_token() in authlib await auth.async_get_access_token()
await session.user() except ClientResponseError as err:
except ConnectTimeout as err: if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}:
_LOGGER.debug("Connection Timeout") raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except Exception: # noqa: BLE001
_LOGGER.error("Authentication Error")
return False
hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() point_session = PointSession(auth)
hass.data[CONFIG_ENTRY_IS_SETUP] = set()
await async_setup_webhook(hass, entry, session) client = MinutPointClient(hass, entry, point_session)
client = MinutPointClient(hass, entry, session)
hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client})
hass.async_create_task(client.update()) hass.async_create_task(client.update())
entry.runtime_data = PointData(client)
await async_setup_webhook(hass, entry, point_session)
# Entries are added in the client.update() function.
return True 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.""" """Set up a webhook to handle binary sensor events."""
if CONF_WEBHOOK_ID not in entry.data: if CONF_WEBHOOK_ID not in entry.data:
webhook_id = webhook.async_generate_id() webhook_id = webhook.async_generate_id()
@ -135,27 +165,26 @@ async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session):
CONF_WEBHOOK_URL: webhook_url, CONF_WEBHOOK_URL: webhook_url,
}, },
) )
await session.update_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], entry.data[CONF_WEBHOOK_ID],
["*"], ["*"],
) )
webhook.async_register( webhook.async_register(
hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook
) )
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
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]) webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
session = hass.data[DOMAIN].pop(entry.entry_id)
await session.remove_webhook() await session.remove_webhook()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok return unload_ok
@ -205,14 +234,6 @@ class MinutPointClient:
async def new_device(device_id, platform): async def new_device(device_id, platform):
"""Load new device.""" """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]:
await self._hass.config_entries.async_forward_entry_setups(
self._config_entry, [platform]
)
self._hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key)
async_dispatcher_send( async_dispatcher_send(
self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id
) )
@ -220,10 +241,16 @@ class MinutPointClient:
self._is_available = True self._is_available = True
for home_id in self._client.homes: for home_id in self._client.homes:
if home_id not in self._known_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") await new_device(home_id, "alarm_control_panel")
self._known_homes.add(home_id) self._known_homes.add(home_id)
for device in self._client.devices: for device in self._client.devices:
if device.device_id not in self._known_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: for platform in PLATFORMS:
await new_device(device.device_id, platform) await new_device(device.device_id, platform)
self._known_devices.add(device.device_id) self._known_devices.add(device.device_id)
@ -262,7 +289,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s
_attr_should_poll = False _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.""" """Initialize the entity."""
self._async_unsub_dispatcher_connect = None self._async_unsub_dispatcher_connect = None
self._client = point_client self._client = point_client
@ -284,7 +311,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s
if device_class: if device_class:
self._attr_name = f"{self._name} {device_class.capitalize()}" self._attr_name = f"{self._name} {device_class.capitalize()}"
def __str__(self): def __str__(self) -> str:
"""Return string representation of device.""" """Return string representation of device."""
return f"MinutPoint {self.name}" return f"MinutPoint {self.name}"
@ -337,3 +364,11 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s
def last_update(self): def last_update(self):
"""Return the last_update time for the device.""" """Return the last_update time for the device."""
return parse_datetime(self.device.last_update) return parse_datetime(self.device.last_update)
@dataclass
class PointData:
"""Point Data."""
client: MinutPointClient
entry_lock: asyncio.Lock = asyncio.Lock()

View File

@ -43,7 +43,7 @@ async def async_setup_entry(
async def async_discover_home(home_id): async def async_discover_home(home_id):
"""Discover and add a discovered home.""" """Discover and add a discovered home."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id] client = config_entry.runtime_data.client
async_add_entities([MinutPointAlarmControl(client, home_id)], True) async_add_entities([MinutPointAlarmControl(client, home_id)], True)
async_dispatcher_connect( async_dispatcher_connect(

View File

@ -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"]

View File

@ -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,
)

View File

@ -49,7 +49,7 @@ async def async_setup_entry(
async def async_discover_sensor(device_id): async def async_discover_sensor(device_id):
"""Discover and add a discovered sensor.""" """Discover and add a discovered sensor."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id] client = config_entry.runtime_data.client
async_add_entities( async_add_entities(
( (
MinutPointBinarySensor(client, device_id, device_name) MinutPointBinarySensor(client, device_id, device_name)

View File

@ -1,197 +1,71 @@
"""Config flow for Minut Point.""" """Config flow for Minut Point."""
import asyncio from collections.abc import Mapping
from collections import OrderedDict
import logging import logging
from typing import Any from typing import Any
from pypoint import PointSession from homeassistant.components.webhook import async_generate_id
import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID
from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
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 .const import DOMAIN from .const import DOMAIN
AUTH_CALLBACK_PATH = "/api/minut"
AUTH_CALLBACK_NAME = "api:minut"
DATA_FLOW_IMPL = "point_flow_implementation" class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Minut Point OAuth2 authentication."""
_LOGGER = logging.getLogger(__name__) DOMAIN = DOMAIN
reauth_entry: ConfigEntry | None = None
@callback @property
# pylint: disable-next=hass-argument-type # see PR 118243 def logger(self) -> logging.Logger:
def register_flow_implementation(hass, domain, client_id, client_secret): """Return logger."""
"""Register a flow implementation. return logging.getLogger(__name__)
domain: Domain of the component responsible for the implementation. async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult:
name: Name of the component. """Handle import from YAML."""
client_id: Client id. return await self.async_step_user()
client_secret: Client secret.
"""
if DATA_FLOW_IMPL not in hass.data:
hass.data[DATA_FLOW_IMPL] = OrderedDict()
hass.data[DATA_FLOW_IMPL][domain] = { async def async_step_reauth(
CONF_CLIENT_ID: client_id, self, entry_data: Mapping[str, Any]
CONF_CLIENT_SECRET: client_secret, ) -> 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(
class PointFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
def __init__(self) -> None:
"""Initialize flow."""
self.flow_impl = None
# pylint: disable-next=hass-return-type # see PR 118243
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: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow start.""" """Dialog that informs the user that reauth is required."""
flows = self.hass.data.get(DATA_FLOW_IMPL, {}) if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
if self._async_current_entries(): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
return self.async_abort(reason="already_setup") """Create an oauth config entry or update existing entry for reauth."""
user_id = str(data[CONF_TOKEN]["user_id"])
if not flows: if not self.reauth_entry:
_LOGGER.debug("no flows") await self.async_set_unique_id(user_id)
return self.async_abort(reason="no_flows") self._abort_if_unique_id_configured()
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))}),
)
# pylint: disable-next=hass-return-type # see PR 118243
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
# pylint: disable-next=hass-return-type # see PR 118243
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.debug("Successfully authenticated Point")
user_email = (await point_session.user()).get("email") or ""
return self.async_create_entry( return self.async_create_entry(
title=user_email, title="Minut Point",
data={ data={**data, CONF_WEBHOOK_ID: async_generate_id()},
"token": token,
"refresh_args": {
CONF_CLIENT_ID: client_id,
CONF_CLIENT_SECRET: client_secret,
},
},
) )
if (
class MinutAuthCallbackView(HomeAssistantView): self.reauth_entry.unique_id is None
"""Minut Authorization Callback View.""" or self.reauth_entry.unique_id == user_id
):
requires_auth = False logging.debug("user_id: %s", user_id)
url = AUTH_CALLBACK_PATH return self.async_update_reload_and_abort(
name = AUTH_CALLBACK_NAME self.reauth_entry,
data={**self.reauth_entry.data, **data},
@staticmethod unique_id=user_id,
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!" return self.async_abort(reason="wrong_account")

View File

@ -7,8 +7,12 @@ DOMAIN = "point"
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
CONF_WEBHOOK_URL = "webhook_url" CONF_WEBHOOK_URL = "webhook_url"
CONF_REFRESH_TOKEN = "refresh_token"
EVENT_RECEIVED = "point_webhook_received" EVENT_RECEIVED = "point_webhook_received"
SIGNAL_UPDATE_ENTITY = "point_update" SIGNAL_UPDATE_ENTITY = "point_update"
SIGNAL_WEBHOOK = "point_webhook" SIGNAL_WEBHOOK = "point_webhook"
POINT_DISCOVERY_NEW = "point_new_{}_{}" POINT_DISCOVERY_NEW = "point_new_{}_{}"
OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize"
OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token"

View File

@ -3,10 +3,10 @@
"name": "Minut Point", "name": "Minut Point",
"codeowners": ["@fredrike"], "codeowners": ["@fredrike"],
"config_flow": true, "config_flow": true,
"dependencies": ["webhook", "http"], "dependencies": ["application_credentials", "http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/point", "documentation": "https://www.home-assistant.io/integrations/point",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pypoint"], "loggers": ["pypoint"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["pypoint==2.3.2"] "requirements": ["pypoint==3.0.0"]
} }

View File

@ -54,7 +54,7 @@ async def async_setup_entry(
async def async_discover_sensor(device_id): async def async_discover_sensor(device_id):
"""Discover and add a discovered sensor.""" """Discover and add a discovered sensor."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id] client = config_entry.runtime_data.client
async_add_entities( async_add_entities(
[ [
MinutPointSensor(client, device_id, description) MinutPointSensor(client, device_id, description)

View File

@ -1,29 +1,31 @@
{ {
"config": { "config": {
"step": { "abort": {
"user": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"description": "[%key:common::config_flow::description::confirm_setup%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"data": { "flow_impl": "Provider" } "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
}, "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"auth": { "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"title": "Authenticate Point", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"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})" "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%]",
"wrong_account": "You can only reauthenticate this account with the same user."
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"
}, },
"error": { "step": {
"no_token": "[%key:common::config_flow::error::invalid_access_token%]", "pick_implementation": {
"follow_link": "Please follow the link and authenticate before pressing Submit" "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}, },
"abort": { "reauth_confirm": {
"already_setup": "[%key:common::config_flow::abort::single_instance_allowed%]", "title": "[%key:common::config_flow::title::reauth%]",
"external_setup": "Point successfully configured from another flow.", "description": "The Point integration needs to re-authenticate your account"
"no_flows": "[%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%]"
} }
} }
} }

View File

@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [
"neato", "neato",
"nest", "nest",
"netatmo", "netatmo",
"point",
"senz", "senz",
"spotify", "spotify",
"tesla_fleet", "tesla_fleet",

View File

@ -2142,7 +2142,7 @@ pypjlink2==1.2.1
pyplaato==0.0.18 pyplaato==0.0.18
# homeassistant.components.point # homeassistant.components.point
pypoint==2.3.2 pypoint==3.0.0
# homeassistant.components.profiler # homeassistant.components.profiler
pyprof2calltree==1.4.5 pyprof2calltree==1.4.5

View File

@ -1723,7 +1723,7 @@ pypjlink2==1.2.1
pyplaato==0.0.18 pyplaato==0.0.18
# homeassistant.components.point # homeassistant.components.point
pypoint==2.3.2 pypoint==3.0.0
# homeassistant.components.profiler # homeassistant.components.profiler
pyprof2calltree==1.4.5 pyprof2calltree==1.4.5

View File

@ -1 +1,12 @@
"""Tests for the Point component.""" """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)

View File

@ -1,153 +1,172 @@
"""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 import pytest
from homeassistant.components.point import DOMAIN, config_flow from homeassistant import config_entries
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.components.application_credentials import (
ClientCredential,
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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
REDIRECT_URL = "https://example.com/auth/external/callback"
def init_config_flow( @pytest.fixture(autouse=True)
hass: HomeAssistant, side_effect: type[Exception] | None = None async def setup_credentials(hass: HomeAssistant) -> None:
) -> config_flow.PointFlowHandler: """Fixture to setup credentials."""
"""Init a configuration flow.""" assert await async_setup_component(hass, "application_credentials", {})
config_flow.register_flow_implementation(hass, DOMAIN, "id", "secret") await async_import_client_credential(
flow = config_flow.PointFlowHandler() hass,
flow._get_authorization_url = AsyncMock( DOMAIN,
return_value="https://example.com", side_effect=side_effect ClientCredential(CLIENT_ID, CLIENT_SECRET),
) )
flow.hass = hass
return flow
@pytest.fixture @pytest.mark.usefixtures("current_request_with_host")
def is_authorized() -> bool: async def test_full_flow(
"""Set PointSession authorized.""" hass: HomeAssistant,
return True hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
@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)
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"
}
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"}
async def test_step_import(hass: HomeAssistant, mock_pypoint) -> None:
"""Test that we trigger import when configuring with client."""
flow = init_config_flow(hass)
result = await flow.async_step_import()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
@pytest.mark.parametrize("is_authorized", [False])
async def test_wrong_code_flow_implementation(
hass: HomeAssistant, mock_pypoint
) -> None: ) -> None:
"""Test wrong code.""" """Check full flow."""
flow = init_config_flow(hass) 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,
},
)
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,
"user_id": "abcd",
},
)
with patch(
"homeassistant.components.point.async_setup_entry", return_value=True
) as mock_setup:
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.parametrize(
("unique_id", "expected", "expected_unique_id"),
[
("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(
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(
domain=DOMAIN,
unique_id=unique_id,
version=1,
data={"id": "timmo", "auth_implementation": DOMAIN},
)
old_entry.add_to_hass(hass)
result = await old_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"])
result = await flow.async_step_code("123ABC")
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "auth_error" assert result["reason"] == expected
assert old_entry.unique_id == expected_unique_id
async def test_not_pick_implementation_if_only_one(hass: HomeAssistant) -> None: async def test_import_flow(
"""Test we allow picking implementation if we have one flow_imp.""" hass: HomeAssistant,
flow = init_config_flow(hass) hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
result = await flow.async_step_user() ) -> None:
assert result["type"] is FlowResultType.FORM """Test import flow."""
assert result["step_id"] == "auth" result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}
)
async def test_abort_if_timeout_generating_auth_url(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM
"""Test we abort if generating authorize url fails.""" assert result["step_id"] == "pick_implementation"
flow = init_config_flow(hass, side_effect=TimeoutError)
result = await flow.async_step_user()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "authorize_url_timeout"
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)
result = await flow.async_step_user()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown_authorize_url_generation"
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()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_code"