diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 201e397ba7d97..4e02fd09046c1 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -3,16 +3,17 @@ from collections import OrderedDict import logging -from pypoint import PointSession +from aiohttp import web_response +from pypoint import MINUT_AUTH_URL, PointSession import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView 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 CONF_REDIRECT_URI, DOMAIN AUTH_CALLBACK_PATH = "/api/minut" AUTH_CALLBACK_NAME = "api:minut" @@ -44,10 +45,23 @@ class PointFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 + code: str | None = None + + @property + def schema(self): + """Return current schema.""" + return vol.Schema( + { + vol.Required(CONF_REDIRECT_URI): str, + } + ) def __init__(self) -> None: """Initialize flow.""" self.flow_impl = None + self.client_id = None + self.client_secret = None + self.redirect_uri = None async def async_step_import(self, user_input=None): """Handle external yaml configuration.""" @@ -55,98 +69,82 @@ async def async_step_import(self, user_input=None): return self.async_abort(reason="already_setup") self.flow_impl = DOMAIN + flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] + self.client_id = flow[CONF_CLIENT_ID] + self.client_secret = flow[CONF_CLIENT_SECRET] return await self.async_step_auth() async def async_step_user(self, user_input=None): """Handle a flow start.""" - flows = self.hass.data.get(DATA_FLOW_IMPL, {}) - if self._async_current_entries(): return self.async_abort(reason="already_setup") - if not flows: - _LOGGER.debug("no flows") + if not self.flow_impl: 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))}), - ) + return await self.async_step_auth() 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 = {} + return self.async_abort(reason="already_setup") if user_input is not None: - errors["base"] = "follow_link" - - try: - async with asyncio.timeout(10): - url = await self._get_authorization_url() - except asyncio.TimeoutError: - return self.async_abort(reason="authorize_url_timeout") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected error generating auth url") - return self.async_abort(reason="unknown_authorize_url_generation") + self.redirect_uri = user_input.get(CONF_REDIRECT_URI) + + try: + async with asyncio.timeout(10): + url = await self._get_authorization_url() + except asyncio.TimeoutError: + return self.async_abort(reason="authorize_url_timeout") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error generating auth url") + return self.async_abort(reason="unknown_authorize_url_generation") + return self.async_external_step( + step_id="code", + url=url, + ) + return self.async_show_form( step_id="auth", - description_placeholders={"authorization_url": url}, - errors=errors, + data_schema=self.schema, ) 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, + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri=self.redirect_uri, ) - self.hass.http.register_view(MinutAuthCallbackView()) + return point_session.create_authorization_url( + MINUT_AUTH_URL, state=self.flow_id + )[0] - return point_session.get_authorization_url - - async def async_step_code(self, code=None): + async def async_step_code(self, user_input=None): """Received code for authentication.""" + if user_input is not None: + self.code = user_input + return self.async_external_step_done(next_step_id="finish") + + async def async_step_finish(self, user_input=None): + """Create point session and entries.""" if self._async_current_entries(): return self.async_abort(reason="already_setup") + code = self.code 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] + client_id = self.client_id + client_secret = self.client_secret point_session = PointSession( async_get_clientsession(self.hass), client_id, client_secret, + redirect_uri=self.redirect_uri, ) token = await point_session.get_access_token(code) _LOGGER.debug("Got new token") @@ -181,9 +179,12 @@ async def get(request): """Receive authorization code.""" hass = request.app["hass"] if "code" in request.query: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": "code"}, data=request.query["code"] - ) + result = await hass.config_entries.flow.async_configure( + flow_id=request.query["state"], user_input=request.query["code"] ) - return "OK!" + if result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP_DONE: + return web_response.Response( + headers={"content-type": "text/html"}, + text="Success! This window can be closed", + ) + return "Error authenticating Minut Point." diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index c21971185f95f..ef7d4dfa34d73 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -6,6 +6,8 @@ SCAN_INTERVAL = timedelta(minutes=1) CONF_WEBHOOK_URL = "webhook_url" +CONF_REDIRECT_URI = "redirect_uri" + EVENT_RECEIVED = "point_webhook_received" SIGNAL_UPDATE_ENTITY = "point_update" SIGNAL_WEBHOOK = "point_webhook" diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index 8a28e314b69d7..3000131aff036 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -1,14 +1,10 @@ { "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})" + "description": "Please fill Redirect URI below, **Submit** and **Accept** access to your Minut account in the popup dialog.", + "data": { "redirect_uri": "Redirect URI" } } }, "create_entry": { diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 9791da76a177f..a77f622a9bc8d 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -1,14 +1,18 @@ """Tests for the Point config flow.""" import asyncio +from http import HTTPStatus from unittest.mock import AsyncMock, patch import pytest from homeassistant import data_entry_flow from homeassistant.components.point import DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant +from tests.typing import ClientSessionGenerator + def init_config_flow(hass, side_effect=None): """Init a configuration flow.""" @@ -43,6 +47,16 @@ def mock_pypoint(is_authorized): yield PointSession +async def test_no_yaml(hass: HomeAssistant) -> None: + """Test if no yaml is configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_flows" + + async def test_abort_if_no_implementation_registered(hass: HomeAssistant) -> None: """Test we abort if no implementation is registered.""" flow = config_flow.PointFlowHandler() @@ -67,25 +81,49 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_setup" + with patch.object(hass.config_entries, "async_entries", return_value=[{}]): + result = await flow.async_step_auth() + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_setup" + + with patch.object(hass.config_entries, "async_entries", return_value=[{}]): + result = await flow.async_step_finish() + assert result["type"] == data_entry_flow.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) + init_config_flow(hass) - result = await flow.async_step_user() - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_CLIENT_ID: "id", CONF_CLIENT_SECRET: "secret"}, + ) - result = await flow.async_step_user({"flow_impl": "test"}) assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] is None assert result["step_id"] == "auth" - assert result["description_placeholders"] == { - "authorization_url": "https://example.com" - } - result = await flow.async_step_code("123ABC") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "redirect_uri": "http://example.com", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "code" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"code"} + ) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP_DONE + assert result["step_id"] == "finish" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"]["token"] == {"access_token": "boo"} assert result["data"]["refresh_args"] == { CONF_CLIENT_ID: "id", CONF_CLIENT_SECRET: "secret", @@ -109,16 +147,16 @@ async def test_wrong_code_flow_implementation( ) -> None: """Test wrong code.""" flow = init_config_flow(hass) - - result = await flow.async_step_code("123ABC") + flow.code = "123ABC" + result = await flow.async_step_finish() assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "auth_error" -async def test_not_pick_implementation_if_only_one(hass: HomeAssistant) -> None: +async def test_user_with_external_conf(hass: HomeAssistant) -> None: """Test we allow picking implementation if we have one flow_imp.""" flow = init_config_flow(hass) - + flow.flow_impl = DOMAIN result = await flow.async_step_user() assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "auth" @@ -128,7 +166,9 @@ 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=asyncio.TimeoutError) - result = await flow.async_step_user() + result = await flow.async_step_auth( + user_input={"redirect_uri": "http://example.com"} + ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "authorize_url_timeout" @@ -137,7 +177,9 @@ async def test_abort_if_exception_generating_auth_url(hass: HomeAssistant) -> No """Test we abort if generating authorize url blows up.""" flow = init_config_flow(hass, side_effect=ValueError) - result = await flow.async_step_user() + result = await flow.async_step_auth( + user_input={"redirect_uri": "http://example.com"} + ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "unknown_authorize_url_generation" @@ -146,6 +188,50 @@ 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() + result = await flow.async_step_finish() assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "no_code" + + +async def test_callback_view( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_pypoint, +) -> None: + """Test callback view.""" + config_flow.register_flow_implementation(hass, "test-other", None, None) + init_config_flow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_CLIENT_ID: "id", CONF_CLIENT_SECRET: "secret"}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "redirect_uri": "http://example.com", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "code" + + client = await hass_client_no_auth() + forward_url = config_flow.AUTH_CALLBACK_PATH + resp = await client.get(forward_url) + assert resp.status == HTTPStatus.OK + + forward_url = ( + f'{config_flow.AUTH_CALLBACK_PATH}?code=ABC123&state={result["flow_id"]}' + ) + + resp = await client.get(forward_url) + assert resp.status == HTTPStatus.OK + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY