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
This commit is contained in:
Joakim Sørensen 2021-08-08 15:41:05 +02:00 committed by GitHub
parent aaddeb0bcd
commit 89bb95b0be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 383 additions and 29 deletions

View File

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

View File

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

View File

@ -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%]"
}
}
}
}

View File

@ -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"
}

View File

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

View File

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