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
parent
7a9da6dde1
commit
1768daf98c
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"]
|
|
@ -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,
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [
|
|||
"neato",
|
||||
"nest",
|
||||
"netatmo",
|
||||
"point",
|
||||
"senz",
|
||||
"spotify",
|
||||
"tesla_fleet",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue