Add ability to reauth sonarr (#40306)

* add ability to reauth sonarr

* Update config_flow.py

* work on reauth

* Update test_init.py

* Update config_flow.py

* Update strings.json

* Update test_config_flow.py

* Update strings.json

* Update test_config_flow.py

* Update __init__.py

* Update __init__.py

* Update config_flow.py

* Update strings.json

* Update config_flow.py

* Update __init__.py

* Update test_config_flow.py

* Update test_config_flow.py

* Update strings.json

* Update test_config_flow.py

* Update config_flow.py

* Update test_config_flow.py

* Update config_flow.py

* Update strings.json

* Update config_flow.py

* Update test_config_flow.py

* Update test_config_flow.py
This commit is contained in:
Chris Talkington 2020-09-28 20:15:59 -05:00 committed by GitHub
parent bc89b63fc6
commit 432133a12a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 180 additions and 27 deletions

View File

@ -1,16 +1,19 @@
"""The Sonarr component.""" """The Sonarr component."""
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import logging
from typing import Any, Dict from typing import Any, Dict
from sonarr import Sonarr, SonarrError from sonarr import Sonarr, SonarrAccessRestricted, SonarrError
from homeassistant.config_entries import ConfigEntry from homeassistant.components import persistent_notification
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_NAME, ATTR_NAME,
CONF_API_KEY, CONF_API_KEY,
CONF_HOST, CONF_HOST,
CONF_PORT, CONF_PORT,
CONF_SOURCE,
CONF_SSL, CONF_SSL,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
) )
@ -36,6 +39,7 @@ from .const import (
PLATFORMS = ["sensor"] PLATFORMS = ["sensor"]
SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: async def async_setup(hass: HomeAssistantType, config: Dict) -> bool:
@ -69,6 +73,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
try: try:
await sonarr.update() await sonarr.update()
except SonarrAccessRestricted:
_async_start_reauth(hass, entry)
return False
except SonarrError as err: except SonarrError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
@ -106,6 +113,24 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo
return unload_ok return unload_ok
def _async_start_reauth(hass: HomeAssistantType, entry: ConfigEntry):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_REAUTH},
data={"config_entry_id": entry.entry_id, **entry.data},
)
)
_LOGGER.error("API Key is no longer valid. Please reauthenticate")
persistent_notification.async_create(
hass,
f"Sonarr integration for the Sonarr API hosted at {entry.entry_data[CONF_HOST]} needs to be re-authenticated. Please go to the integrations page to re-configure it.",
"Sonarr re-authentication",
"sonarr_reauth",
)
async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None: async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
async_dispatcher_send( async_dispatcher_send(

View File

@ -5,6 +5,7 @@ from typing import Any, Dict, Optional
from sonarr import Sonarr, SonarrAccessRestricted, SonarrError from sonarr import Sonarr, SonarrAccessRestricted, SonarrError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow, OptionsFlow from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow, OptionsFlow
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
@ -61,6 +62,12 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Initialize the flow."""
self._reauth = False
self._entry_id = None
self._entry_data = {}
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry): def async_get_options_flow(config_entry):
@ -73,30 +80,87 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a flow initiated by configuration file.""" """Handle a flow initiated by configuration file."""
return await self.async_step_user(user_input) return await self.async_step_user(user_input)
async def async_step_reauth(
self, data: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Handle configuration by re-auth."""
self._reauth = True
self._entry_data = dict(data)
self._entry_id = self._entry_data.pop("config_entry_id")
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={"host": self._entry_data[CONF_HOST]},
data_schema=vol.Schema({}),
errors={},
)
assert self.hass
persistent_notification.async_dismiss(self.hass, "sonarr_reauth")
return await self.async_step_user()
async def async_step_user( async def async_step_user(
self, user_input: Optional[ConfigType] = None self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Handle a flow initiated by the user.""" """Handle a flow initiated by the user."""
if user_input is None: errors = {}
return self._show_setup_form()
if CONF_VERIFY_SSL not in user_input: if user_input is not None:
user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL if self._reauth:
user_input = {**self._entry_data, **user_input}
try: if CONF_VERIFY_SSL not in user_input:
await validate_input(self.hass, user_input) user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL
except SonarrAccessRestricted:
return self._show_setup_form({"base": "invalid_auth"})
except SonarrError:
return self._show_setup_form({"base": "cannot_connect"})
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) try:
await validate_input(self.hass, user_input)
except SonarrAccessRestricted:
errors = {"base": "invalid_auth"}
except SonarrError:
errors = {"base": "cannot_connect"}
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
else:
if self._reauth:
return await self._async_reauth_update_entry(
self._entry_id, user_input
)
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
data_schema = self._get_user_data_schema()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(data_schema),
errors=errors,
)
async def _async_reauth_update_entry(
self, entry_id: str, data: dict
) -> Dict[str, Any]:
"""Update existing config entry."""
entry = self.hass.config_entries.async_get_entry(entry_id)
self.hass.config_entries.async_update_entry(entry, data=data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
def _get_user_data_schema(self) -> Dict[str, Any]:
"""Get the data schema to display user form."""
if self._reauth:
return {vol.Required(CONF_API_KEY): str}
def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
"""Show the setup form to the user."""
data_schema = { data_schema = {
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST): str,
vol.Required(CONF_API_KEY): str, vol.Required(CONF_API_KEY): str,
@ -110,11 +174,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL) vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL)
] = bool ] = bool
return self.async_show_form( return data_schema
step_id="user",
data_schema=vol.Schema(data_schema),
errors=errors or {},
)
class SonarrOptionsFlowHandler(OptionsFlow): class SonarrOptionsFlowHandler(OptionsFlow):

View File

@ -13,6 +13,10 @@
"ssl": "Sonarr uses a SSL certificate", "ssl": "Sonarr uses a SSL certificate",
"verify_ssl": "Sonarr uses a proper certificate" "verify_ssl": "Sonarr uses a proper certificate"
} }
},
"reauth_confirm": {
"title": "Re-authenticate with Sonarr",
"description": "The Sonarr integration needs to be manually re-authenticated with the Sonarr API hosted at: {host}"
} }
}, },
"error": { "error": {
@ -21,6 +25,7 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "Successfully re-authenticated",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },

View File

@ -35,6 +35,8 @@ MOCK_SENSOR_CONFIG = {
"days": 3, "days": 3,
} }
MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"}
MOCK_USER_INPUT = { MOCK_USER_INPUT = {
CONF_HOST: HOST, CONF_HOST: HOST,
CONF_PORT: PORT, CONF_PORT: PORT,

View File

@ -6,8 +6,8 @@ from homeassistant.components.sonarr.const import (
DEFAULT_WANTED_MAX_ITEMS, DEFAULT_WANTED_MAX_ITEMS,
DOMAIN, 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_HOST, CONF_SOURCE, CONF_VERIFY_SSL from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_SOURCE, CONF_VERIFY_SSL
from homeassistant.data_entry_flow import ( from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT, RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_CREATE_ENTRY,
@ -18,6 +18,7 @@ from homeassistant.helpers.typing import HomeAssistantType
from tests.async_mock import patch from tests.async_mock import patch
from tests.components.sonarr import ( from tests.components.sonarr import (
HOST, HOST,
MOCK_REAUTH_INPUT,
MOCK_USER_INPUT, MOCK_USER_INPUT,
_patch_async_setup, _patch_async_setup,
_patch_async_setup_entry, _patch_async_setup_entry,
@ -98,7 +99,7 @@ async def test_unknown_error(
async def test_full_import_flow_implementation( async def test_full_import_flow_implementation(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test the full manual user flow from start to finish.""" """Test the full manual import flow from start to finish."""
mock_connection(aioclient_mock) mock_connection(aioclient_mock)
user_input = MOCK_USER_INPUT.copy() user_input = MOCK_USER_INPUT.copy()
@ -117,6 +118,44 @@ async def test_full_import_flow_implementation(
assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_HOST] == HOST
async def test_full_reauth_flow_implementation(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the manual reauth flow from start to finish."""
entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True)
assert entry
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_REAUTH},
data={"config_entry_id": entry.entry_id, **entry.data},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
user_input = MOCK_REAUTH_INPUT.copy()
with _patch_async_setup(), _patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=user_input
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "reauth_successful"
assert entry.data[CONF_API_KEY] == "test-api-key-reauth"
mock_setup_entry.assert_called_once()
async def test_full_user_flow_implementation( async def test_full_user_flow_implementation(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
@ -180,7 +219,9 @@ async def test_full_user_flow_advanced_options(
async def test_options_flow(hass, aioclient_mock: AiohttpClientMocker): async def test_options_flow(hass, aioclient_mock: AiohttpClientMocker):
"""Test updating options.""" """Test updating options."""
entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) with patch("homeassistant.components.sonarr.PLATFORMS", []):
entry = await setup_integration(hass, aioclient_mock)
assert entry.options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS assert entry.options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS
assert entry.options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS assert entry.options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS
@ -194,6 +235,7 @@ async def test_options_flow(hass, aioclient_mock: AiohttpClientMocker):
result["flow_id"], result["flow_id"],
user_input={CONF_UPCOMING_DAYS: 2, CONF_WANTED_MAX_ITEMS: 100}, user_input={CONF_UPCOMING_DAYS: 2, CONF_WANTED_MAX_ITEMS: 100},
) )
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_UPCOMING_DAYS] == 2 assert result["data"][CONF_UPCOMING_DAYS] == 2

View File

@ -3,8 +3,11 @@ from homeassistant.components.sonarr.const import DOMAIN
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ENTRY_STATE_LOADED, ENTRY_STATE_LOADED,
ENTRY_STATE_NOT_LOADED, ENTRY_STATE_NOT_LOADED,
ENTRY_STATE_SETUP_ERROR,
ENTRY_STATE_SETUP_RETRY, ENTRY_STATE_SETUP_RETRY,
SOURCE_REAUTH,
) )
from homeassistant.const import CONF_SOURCE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.async_mock import patch from tests.async_mock import patch
@ -20,6 +23,22 @@ async def test_config_entry_not_ready(
assert entry.state == ENTRY_STATE_SETUP_RETRY assert entry.state == ENTRY_STATE_SETUP_RETRY
async def test_config_entry_reauth(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the configuration entry needing to be re-authenticated."""
with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init:
entry = await setup_integration(hass, aioclient_mock, invalid_auth=True)
assert entry.state == ENTRY_STATE_SETUP_ERROR
mock_flow_init.assert_called_once_with(
DOMAIN,
context={CONF_SOURCE: SOURCE_REAUTH},
data={"config_entry_id": entry.entry_id, **entry.data},
)
async def test_unload_config_entry( async def test_unload_config_entry(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None: