From 90a9d64f673b217da41cff89947b590c60f70de2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Dec 2021 17:28:35 +0100 Subject: [PATCH] Add reauthentication handling to PVOutput (#62932) --- .coveragerc | 1 + homeassistant/components/pvoutput/__init__.py | 22 +-- .../components/pvoutput/config_flow.py | 51 ++++++- .../components/pvoutput/coordinator.py | 37 +++++ .../components/pvoutput/strings.json | 9 ++ .../components/pvoutput/translations/en.json | 9 ++ tests/components/pvoutput/test_config_flow.py | 133 +++++++++++++++++- 7 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/pvoutput/coordinator.py diff --git a/.coveragerc b/.coveragerc index 07cac3d3da1..65e4cb68927 100644 --- a/.coveragerc +++ b/.coveragerc @@ -866,6 +866,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pvoutput/__init__.py + homeassistant/components/pvoutput/coordinator.py homeassistant/components/pvoutput/sensor.py homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/sensor.py diff --git a/homeassistant/components/pvoutput/__init__.py b/homeassistant/components/pvoutput/__init__.py index aaac29ef50e..6457a4d25c2 100644 --- a/homeassistant/components/pvoutput/__init__.py +++ b/homeassistant/components/pvoutput/__init__.py @@ -1,32 +1,16 @@ """The PVOutput integration.""" from __future__ import annotations -from pvo import PVOutput, Status - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_SYSTEM_ID, DOMAIN, LOGGER, PLATFORMS, SCAN_INTERVAL +from .const import DOMAIN, PLATFORMS +from .coordinator import PVOutputDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up PVOutput from a config entry.""" - pvoutput = PVOutput( - api_key=entry.data[CONF_API_KEY], - system_id=entry.data[CONF_SYSTEM_ID], - session=async_get_clientsession(hass), - ) - - coordinator: DataUpdateCoordinator[Status] = DataUpdateCoordinator( - hass, - LOGGER, - name=f"{DOMAIN}_{entry.data[CONF_SYSTEM_ID]}", - update_interval=SCAN_INTERVAL, - update_method=pvoutput.status, - ) + coordinator = PVOutputDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index 0de24e9ae0e..aa197061466 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from pvo import PVOutput, PVOutputAuthenticationError, PVOutputError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -26,12 +26,13 @@ async def validate_input(hass: HomeAssistant, *, api_key: str, system_id: int) - await pvoutput.status() -class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): +class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for PVOutput.""" VERSION = 1 imported_name: str | None = None + reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -90,3 +91,49 @@ class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): CONF_API_KEY: config[CONF_API_KEY], } ) + + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with PVOutput.""" + 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( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with PVOutput.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + await validate_input( + self.hass, + api_key=user_input[CONF_API_KEY], + system_id=self.reauth_entry.data[CONF_SYSTEM_ID], + ) + except PVOutputAuthenticationError: + errors["base"] = "invalid_auth" + except PVOutputError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={ + "account_url": "https://pvoutput.org/account.jsp" + }, + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/pvoutput/coordinator.py b/homeassistant/components/pvoutput/coordinator.py new file mode 100644 index 00000000000..ca867979dec --- /dev/null +++ b/homeassistant/components/pvoutput/coordinator.py @@ -0,0 +1,37 @@ +"""DataUpdateCoordinator for the PVOutput integration.""" +from __future__ import annotations + +from pvo import PVOutput, PVOutputAuthenticationError, Status + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_SYSTEM_ID, DOMAIN, LOGGER, SCAN_INTERVAL + + +class PVOutputDataUpdateCoordinator(DataUpdateCoordinator): + """The PVOutput Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the PVOutput coordinator.""" + self.config_entry = entry + self.pvoutput = PVOutput( + api_key=entry.data[CONF_API_KEY], + system_id=entry.data[CONF_SYSTEM_ID], + session=async_get_clientsession(hass), + ) + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> Status: + """Fetch system status from PVOutput.""" + try: + return await self.pvoutput.status() + except PVOutputAuthenticationError as err: + raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 0093181558a..513866025a4 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -7,11 +7,20 @@ "system_id": "System ID", "api_key": "[%key:common::config_flow::data::api_key%]" } + }, + "reauth_confirm": { + "description":"To re-authenticate with PVOutput you'll need to get the API key at {account_url}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/pvoutput/translations/en.json b/homeassistant/components/pvoutput/translations/en.json index 6b34d9d2b0d..ad2194232e7 100644 --- a/homeassistant/components/pvoutput/translations/en.json +++ b/homeassistant/components/pvoutput/translations/en.json @@ -1,10 +1,19 @@ { "config": { + "abort": { + "reauth_successful": "Re-authentication was successful" + }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + }, + "description": "To re-authenticate with PVOutput you'll need to get the API key at {account_url}." + }, "user": { "data": { "api_key": "API Key", diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index a4a7392bb91..0c060a75a9d 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from pvo import PVOutputAuthenticationError, PVOutputConnectionError from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( @@ -176,3 +176,134 @@ async def test_import_flow( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "some_new_key"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "some_new_key", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_reauth_with_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow with an authentication error. + + This tests tests a reauth flow, with a case the user enters an invalid + API key, but recover by entering the correct one. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_pvoutput_config_flow.status.side_effect = PVOutputAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "invalid_key"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + mock_pvoutput_config_flow.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: "valid_key"}, + ) + await hass.async_block_till_done() + + assert result3.get("type") == RESULT_TYPE_ABORT + assert result3.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "valid_key", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 2 + + +async def test_reauth_api_error( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test API error during reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_pvoutput_config_flow.status.side_effect = PVOutputConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "some_new_key"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "cannot_connect"}