diff --git a/homeassistant/components/karakeep/config_flow.py b/homeassistant/components/karakeep/config_flow.py index b1413a4c0858..72bf593c80ea 100644 --- a/homeassistant/components/karakeep/config_flow.py +++ b/homeassistant/components/karakeep/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Karakeep.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -28,6 +29,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str}) class KarakeepConfigFlow(ConfigFlow, domain=DOMAIN): @@ -92,6 +94,38 @@ class KarakeepConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication with a new API token.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + token = user_input[CONF_TOKEN].strip() + + errors = await self._async_validate_input( + reauth_entry.data[CONF_URL], + token, + reauth_entry.data[CONF_VERIFY_SSL], + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_TOKEN: token} + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + def _normalize_url(raw_url: str) -> str | None: """Return the normalized base URL, or None if it is not a valid URL.""" diff --git a/homeassistant/components/karakeep/coordinator.py b/homeassistant/components/karakeep/coordinator.py index 01033def739f..1311b7e0d061 100644 --- a/homeassistant/components/karakeep/coordinator.py +++ b/homeassistant/components/karakeep/coordinator.py @@ -14,6 +14,7 @@ from aiokarakeep import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, UPDATE_INTERVAL @@ -60,7 +61,10 @@ class KarakeepDataUpdateCoordinator(DataUpdateCoordinator[KarakeepStats]): try: return await self.client.async_get_stats() except KarakeepAuthError as err: - raise UpdateFailed("Invalid Karakeep API token") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err except KarakeepConnectionError as err: raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err except (KarakeepApiError, KarakeepInvalidResponseError) as err: diff --git a/homeassistant/components/karakeep/quality_scale.yaml b/homeassistant/components/karakeep/quality_scale.yaml index 4df22ff58d89..e5237bda044a 100644 --- a/homeassistant/components/karakeep/quality_scale.yaml +++ b/homeassistant/components/karakeep/quality_scale.yaml @@ -44,7 +44,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -68,9 +68,7 @@ rules: entity-device-class: todo entity-disabled-by-default: todo entity-translations: done - exception-translations: - status: exempt - comment: This integration does not raise translatable Home Assistant exceptions. + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/karakeep/strings.json b/homeassistant/components/karakeep/strings.json index 1aceb6c4e076..b013711c9b2d 100644 --- a/homeassistant/components/karakeep/strings.json +++ b/homeassistant/components/karakeep/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "api_error": "The Karakeep API returned an unexpected response.", @@ -11,6 +12,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "token": "[%key:component::karakeep::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::karakeep::config::step::user::data_description::token%]" + }, + "description": "The API token for your Karakeep instance is no longer valid. Enter a new one to reconnect." + }, "user": { "data": { "token": "API token", @@ -53,5 +63,10 @@ "unit_of_measurement": "tags" } } + }, + "exceptions": { + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + } } } diff --git a/tests/components/karakeep/const.py b/tests/components/karakeep/const.py index 78566ff89232..87a7f5732867 100644 --- a/tests/components/karakeep/const.py +++ b/tests/components/karakeep/const.py @@ -13,4 +13,5 @@ TEST_STATS = KarakeepStats( TEST_VERSION = "0.32.0" TEST_TOKEN = "test-token" +NEW_TOKEN = "new-test-token" TEST_URL = "https://karakeep.example.com" diff --git a/tests/components/karakeep/test_config_flow.py b/tests/components/karakeep/test_config_flow.py index 89cfb333b28e..9885f4a4dad3 100644 --- a/tests/components/karakeep/test_config_flow.py +++ b/tests/components/karakeep/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_TOKEN, TEST_URL +from .const import NEW_TOKEN, TEST_TOKEN, TEST_URL from tests.common import MockConfigEntry @@ -161,3 +161,74 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" mock_karakeep_client.async_get_stats.assert_not_awaited() + + +@pytest.mark.usefixtures("mock_karakeep_client") +async def test_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication flow updates the token.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: f" {NEW_TOKEN} "}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_URL: TEST_URL, + CONF_TOKEN: NEW_TOKEN, + CONF_VERIFY_SSL: True, + } + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (KarakeepAuthError("Invalid token", 401), "invalid_auth"), + (KarakeepConnectionError("Cannot connect"), "cannot_connect"), + (KarakeepApiError("API error", 500), "api_error"), + (Exception("Boom"), "unknown"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test the reauthentication flow shows errors and recovers.""" + mock_config_entry.add_to_hass(hass) + mock_karakeep_client.async_get_stats.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: NEW_TOKEN}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_karakeep_client.async_get_stats.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: NEW_TOKEN}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_TOKEN] == NEW_TOKEN diff --git a/tests/components/karakeep/test_init.py b/tests/components/karakeep/test_init.py index f356112273ae..e38f581e3f5a 100644 --- a/tests/components/karakeep/test_init.py +++ b/tests/components/karakeep/test_init.py @@ -11,7 +11,7 @@ from aiokarakeep import ( import pytest from homeassistant.components.karakeep.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 @@ -40,7 +40,6 @@ async def test_setup_entry( @pytest.mark.parametrize( "side_effect", [ - KarakeepAuthError("Invalid token", 401), KarakeepConnectionError("Cannot connect"), KarakeepApiError("API error", 500), KarakeepInvalidResponseError("Invalid response"), @@ -60,6 +59,23 @@ async def test_setup_entry_update_failure( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_entry_auth_failure_starts_reauth( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test an invalid token puts the entry in error and starts a reauth flow.""" + mock_karakeep_client.async_get_stats.side_effect = KarakeepAuthError( + "Invalid token", 401 + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + async def test_setup_entry_version_failure( hass: HomeAssistant, mock_karakeep_client: AsyncMock,