diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index f25c3410edb..2e9e6bb71f7 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -3,11 +3,11 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator from .models import JellyfinData @@ -30,9 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: user_id, connect_result = await validate_input(hass, dict(entry.data), client) except CannotConnect as ex: raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex - except InvalidAuth: - LOGGER.error("Failed to login to Jellyfin server") - return False + except InvalidAuth as ex: + raise ConfigEntryAuthFailed(ex) from ex server_info: dict[str, Any] = connect_result["Servers"][0] diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 84b78d51926..84360ed053e 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Jellyfin integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -24,6 +25,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD, default=""): str, + } +) + def _generate_client_device_id() -> str: """Generate a random UUID4 string to identify ourselves.""" @@ -38,6 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Jellyfin config flow.""" self.client_device_id: str | None = None + self.entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,3 +91,41 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.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: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + assert self.entry is not None + new_input = self.entry.data | user_input + + if self.client_device_id is None: + self.client_device_id = _generate_client_device_id() + + client = create_client(device_id=self.client_device_id) + try: + await validate_input(self.hass, new_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(ex) + else: + self.hass.config_entries.async_update_entry(self.entry, data=new_input) + + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 8d74d416a94..3e8965da785 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Jellyfin integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]", @@ -16,7 +23,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index 4953824a1c5..157c25b4af4 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -2,6 +2,18 @@ from typing import Final +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + TEST_URL: Final = "https://example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" + +USER_INPUT: Final = { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, +} + +REAUTH_INPUT: Final = { + CONF_PASSWORD: TEST_PASSWORD, +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index 51aa4bccc92..c59efd7efb9 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from . import async_load_json_fixture -from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME +from .const import REAUTH_INPUT, TEST_PASSWORD, TEST_URL, TEST_USERNAME, USER_INPUT from tests.common import MockConfigEntry @@ -44,11 +44,7 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -73,7 +69,7 @@ async def test_form_cannot_connect( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test we handle an unreachable server.""" + """Test configuration with an unreachable server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -86,11 +82,7 @@ async def test_form_cannot_connect( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -106,7 +98,7 @@ async def test_form_invalid_auth( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test configuration with invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -119,11 +111,7 @@ async def test_form_invalid_auth( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -137,7 +125,7 @@ async def test_form_invalid_auth( async def test_form_exception( hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock ) -> None: - """Test we handle an unexpected exception during server setup.""" + """Test configuration with an unexpected exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -148,11 +136,7 @@ async def test_form_exception( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -168,7 +152,7 @@ async def test_form_persists_device_id_on_error( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test persisting the device id on error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -182,11 +166,7 @@ async def test_form_persists_device_id_on_error( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -200,11 +180,7 @@ async def test_form_persists_device_id_on_error( result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -216,3 +192,244 @@ async def test_form_persists_device_id_on_error( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, } + + +async def test_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Complete the reauth + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unreachable server during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with unreachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete reauth with reachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address.json" + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test invalid credentials during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with invalid credentials + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 + + # Complete reauth with valid credentials + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unexpected exception during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform a reauth with an unknown exception + mock_client.auth.connect_to_address.side_effect = Exception("UnknownException") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete the reauth without an exception + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + mock_client.auth.connect_to_address.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 9af73391d18..eb184592bb8 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -67,6 +67,10 @@ async def test_invalid_auth( mock_config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + async def test_load_unload_config_entry( hass: HomeAssistant,