From 89bb95b0bee72c275d03bcc08df3f5f863bd10ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 8 Aug 2021 15:41:05 +0200 Subject: [PATCH] Add re-authentication to Uptime Robot (#54226) * Add reauthentication to Uptime Robot * Fix en strings * format * Fix docstring * Remove unused patch * Handle no existing entry * Handle account mismatch during reauthentication * Add test to validate reauth is triggered properly * Test reauth after setup * Adjust tests * Add full context for reauth init --- .../components/uptimerobot/__init__.py | 10 +- .../components/uptimerobot/config_flow.py | 42 +++- .../components/uptimerobot/strings.json | 45 ++-- .../uptimerobot/translations/en.json | 11 +- .../uptimerobot/test_config_flow.py | 197 +++++++++++++++++- tests/components/uptimerobot/test_init.py | 107 ++++++++++ 6 files changed, 383 insertions(+), 29 deletions(-) create mode 100644 tests/components/uptimerobot/test_init.py diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 4e6ff7908ee..4eaef45c4d2 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,11 +1,17 @@ """The Uptime Robot integration.""" from __future__ import annotations -from pyuptimerobot import UptimeRobot, UptimeRobotException, UptimeRobotMonitor +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) 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.device_registry import ( DeviceRegistry, @@ -74,6 +80,8 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator): """Update data.""" try: response = await self._api.async_get_monitors() + except UptimeRobotAuthenticationException as exception: + raise ConfigEntryAuthFailed(exception) from exception except UptimeRobotException as exception: raise UpdateFailed(exception) from exception else: diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 7bab74fa03e..1e8bec992ad 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -58,15 +58,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if response and response.data and response.data.email else None ) - if account: - await self.async_set_unique_id(str(account.user_id)) - self._abort_if_unique_id_configured() return errors, account async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} if user_input is None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA @@ -74,12 +70,48 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors, account = await self._validate_input(user_input) if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() return self.async_create_entry(title=account.email, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Return the reauth confirm step.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA + ) + errors, account = await self._validate_input(user_input) + if account: + if self.context.get("unique_id") and self.context["unique_id"] != str( + account.user_id + ): + errors["base"] = "reauth_failed_matching_account" + else: + existing_entry = await self.async_set_unique_id(str(account.user_id)) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_failed_existing") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + async def async_step_import(self, import_config: ConfigType) -> FlowResult: """Import a config entry from configuration.yaml.""" for entry in self._async_current_entries(): @@ -93,5 +125,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _, account = await self._validate_input(imported_config) if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() return self.async_create_entry(title=account.email, data=imported_config) return self.async_abort(reason="unknown") diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index f51061eec33..094130b470d 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -1,20 +1,31 @@ { - "config": { - "step": { - "user": { - "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" + "config": { + "step": { + "user": { + "description": "You need to supply a read-only API key from Uptime Robot", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to supply a new read-only API key from Uptime Robot", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "unknown": "[%key:common::config_flow::error::unknown%]" } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "unknown": "[%key:common::config_flow::error::unknown%]" } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json index d23431fa888..8140c84897f 100644 --- a/homeassistant/components/uptimerobot/translations/en.json +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Account is already configured", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -11,6 +12,14 @@ }, "step": { "user": { + "description": "You need to supply a read-only API key from Uptime Robot", + "data": { + "api_key": "API Key" + } + }, + "reauth_confirm": { + "title": "Reauthenticate Integration", + "description": "You need to supply a read-only API key from Uptime Robot", "data": { "api_key": "API Key" } diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 41f0b6b639e..967e1b499f5 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -70,7 +70,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"]["base"] == "cannot_connect" async def test_form_unexpected_error(hass: HomeAssistant) -> None: @@ -88,7 +88,7 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: {"api_key": "1234"}, ) - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"]["base"] == "unknown" async def test_form_api_key_error(hass: HomeAssistant) -> None: @@ -106,7 +106,7 @@ async def test_form_api_key_error(hass: HomeAssistant) -> None: {"api_key": "1234"}, ) - assert result2["errors"] == {"base": "invalid_api_key"} + assert result2["errors"]["base"] == "invalid_api_key" async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: @@ -129,7 +129,7 @@ async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> {"api_key": "1234"}, ) - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"]["base"] == "unknown" assert "test error from API." in caplog.text @@ -211,7 +211,7 @@ async def test_user_unique_id_already_exists(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None with patch( @@ -233,5 +233,190 @@ async def test_user_unique_id_already_exists(hass): await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 - assert result2["type"] == "abort" + assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" + + +async def test_reauthentication(hass): + """Test Uptime Robot reauthentication.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure(hass): + """Test Uptime Robot reauthentication failure.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "fail", + "error": {"message": "test error from API."}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == "unknown" + + +async def test_reauthentication_failure_no_existing_entry(hass): + """Test Uptime Robot reauthentication with no existing entry.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567890}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_failed_existing" + + +async def test_reauthentication_failure_account_not_matching(hass): + """Test Uptime Robot reauthentication failure when using another account.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "account": {"email": "test@test.test", "user_id": 1234567891}, + } + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "1234"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == "reauth_failed_matching_account" diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py new file mode 100644 index 00000000000..b4534af763a --- /dev/null +++ b/tests/components/uptimerobot/test_init.py @@ -0,0 +1,107 @@ +"""Test the Uptime Robot init.""" +import datetime +from unittest.mock import patch + +from pytest import LogCaptureFixture +from pyuptimerobot import UptimeRobotApiResponse +from pyuptimerobot.exceptions import UptimeRobotAuthenticationException + +from homeassistant import config_entries +from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_reauthentication_trigger_in_setup( + hass: HomeAssistant, caplog: LogCaptureFixture +): + """Test reauthentication trigger.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="test@test.test", + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + source=config_entries.SOURCE_USER, + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + assert mock_config_entry.reason == "could not authenticate" + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + assert ( + "Config entry 'test@test.test' for uptimerobot integration could not authenticate" + in caplog.text + ) + + +async def test_reauthentication_trigger_after_setup( + hass: HomeAssistant, caplog: LogCaptureFixture +): + """Test reauthentication trigger.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="test@test.test", + data={"platform": DOMAIN, "api_key": "1234"}, + unique_id="1234567890", + source=config_entries.SOURCE_USER, + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "monitors": [ + {"id": 1234, "friendly_name": "Test monitor", "status": 2} + ], + } + ), + ): + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + binary_sensor = hass.states.get("binary_sensor.test_monitor") + assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED + assert binary_sensor.state == "on" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + + async_fire_time_changed(hass, dt.utcnow() + datetime.timedelta(seconds=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + binary_sensor = hass.states.get("binary_sensor.test_monitor") + + assert binary_sensor.state == "unavailable" + assert "Authentication failed while fetching uptimerobot data" in caplog.text + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id