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."""
import asyncio
from dataclasses import dataclass
from http import HTTPStatus
import logging
from aiohttp import web
from httpx import ConnectTimeout
from aiohttp import ClientError, ClientResponseError, web
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.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 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.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
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@ -29,10 +36,11 @@ from homeassistant.helpers.dispatcher import (
)
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 config_flow
from . import api
from .const import (
CONF_WEBHOOK_URL,
DOMAIN,
@ -45,11 +53,10 @@ from .const import (
_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[PointData]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@ -70,57 +77,80 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
conf = config[DOMAIN]
config_flow.register_flow_implementation(
hass, DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]
async_create_issue(
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",
},
)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
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
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Point from a config entry."""
async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Set up Minut Point from a config entry."""
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}
if "auth_implementation" not in entry.data:
raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
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,
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session
)
try:
# the call to user() implicitly calls ensure_active_token() in authlib
await session.user()
except ConnectTimeout as err:
_LOGGER.debug("Connection Timeout")
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
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()
point_session = PointSession(auth)
await async_setup_webhook(hass, entry, session)
client = MinutPointClient(hass, entry, session)
hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client})
client = MinutPointClient(hass, entry, point_session)
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
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()
@ -135,27 +165,26 @@ async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session):
CONF_WEBHOOK_URL: webhook_url,
},
)
await session.update_webhook(
entry.data[CONF_WEBHOOK_URL],
webhook.async_generate_url(hass, entry.data[CONF_WEBHOOK_ID]),
entry.data[CONF_WEBHOOK_ID],
["*"],
)
webhook.async_register(
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."""
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, 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])
await session.remove_webhook()
return unload_ok
@ -205,14 +234,6 @@ class MinutPointClient:
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]:
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(
self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id
)
@ -220,10 +241,16 @@ class MinutPointClient:
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)
@ -262,7 +289,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s
_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
@ -284,7 +311,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s
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}"
@ -337,3 +364,11 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s
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()

View File

@ -43,7 +43,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.client
async_add_entities([MinutPointAlarmControl(client, home_id)], True)
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):
"""Discover and add a discovered sensor."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id]
client = config_entry.runtime_data.client
async_add_entities(
(
MinutPointBinarySensor(client, device_id, device_name)

View File

@ -1,197 +1,71 @@
"""Config flow for Minut Point."""
import asyncio
from collections import OrderedDict
from collections.abc import Mapping
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, 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 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
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
# pylint: disable-next=hass-argument-type # see PR 118243
def register_flow_implementation(hass, domain, client_id, client_secret):
"""Register a flow implementation.
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
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()
async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML."""
return await self.async_step_user()
hass.data[DATA_FLOW_IMPL][domain] = {
CONF_CLIENT_ID: client_id,
CONF_CLIENT_SECRET: client_secret,
}
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()
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(
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow start."""
flows = self.hass.data.get(DATA_FLOW_IMPL, {})
"""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()
if self._async_current_entries():
return self.async_abort(reason="already_setup")
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]["user_id"])
if not self.reauth_entry:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
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))}),
)
# 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(
title=user_email,
data={
"token": token,
"refresh_args": {
CONF_CLIENT_ID: client_id,
CONF_CLIENT_SECRET: client_secret,
},
},
)
class MinutAuthCallbackView(HomeAssistantView):
"""Minut Authorization Callback View."""
requires_auth = False
url = AUTH_CALLBACK_PATH
name = AUTH_CALLBACK_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 self.async_create_entry(
title="Minut Point",
data={**data, CONF_WEBHOOK_ID: async_generate_id()},
)
return "OK!"
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},
unique_id=user_id,
)
return self.async_abort(reason="wrong_account")

View File

@ -7,8 +7,12 @@ DOMAIN = "point"
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"
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",
"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"],
"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):
"""Discover and add a discovered sensor."""
client = hass.data[POINT_DOMAIN][config_entry.entry_id]
client = config_entry.runtime_data.client
async_add_entities(
[
MinutPointSensor(client, device_id, description)

View File

@ -1,29 +1,31 @@
{
"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})"
}
"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%]",
"wrong_account": "You can only reauthenticate this account with the same user."
},
"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%]",
"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%]"
"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"
}
}
}
}

View File

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

View File

@ -2142,7 +2142,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

View File

@ -1723,7 +1723,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

View File

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

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
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.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
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(
hass: HomeAssistant, side_effect: type[Exception] | None = None
) -> config_flow.PointFlowHandler:
"""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
@pytest.fixture(autouse=True)
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),
)
flow.hass = hass
return flow
@pytest.fixture
def is_authorized() -> bool:
"""Set PointSession authorized."""
return True
@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
@pytest.mark.usefixtures("current_request_with_host")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test wrong code."""
flow = init_config_flow(hass)
"""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,
},
)
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["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:
"""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"
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)
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"
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": SOURCE_IMPORT}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pick_implementation"