From 3c7dd93c7fb46b94d79de6832c5d1b750abf2870 Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Thu, 5 Mar 2026 17:16:03 +0400 Subject: [PATCH] Add reauthentication flow to Ghost integration (Silver) (#164847) --- homeassistant/components/ghost/config_flow.py | 53 +++++++++++ homeassistant/components/ghost/manifest.json | 2 +- .../components/ghost/quality_scale.yaml | 2 +- homeassistant/components/ghost/strings.json | 13 ++- tests/components/ghost/test_config_flow.py | 89 +++++++++++++++++++ 5 files changed, 156 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ghost/config_flow.py b/homeassistant/components/ghost/config_flow.py index 017bcbaca78a..53567e496afb 100644 --- a/homeassistant/components/ghost/config_flow.py +++ b/homeassistant/components/ghost/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -23,12 +24,64 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADMIN_API_KEY): str, + } +) + class GhostConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ghost.""" VERSION = 1 + 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: + """Handle reauth confirmation.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + admin_api_key = user_input[CONF_ADMIN_API_KEY] + + if ":" not in admin_api_key: + errors["base"] = "invalid_api_key" + else: + try: + await self._validate_credentials( + reauth_entry.data[CONF_API_URL], admin_api_key + ) + except GhostAuthError: + errors["base"] = "invalid_auth" + except GhostError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during Ghost reauth") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "title": reauth_entry.title, + "docs_url": "https://account.ghost.org/?r=settings/integrations/new", + }, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/ghost/manifest.json b/homeassistant/components/ghost/manifest.json index 6b263540c6a5..fc257c81e308 100644 --- a/homeassistant/components/ghost/manifest.json +++ b/homeassistant/components/ghost/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioghost"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aioghost==0.4.0"] } diff --git a/homeassistant/components/ghost/quality_scale.yaml b/homeassistant/components/ghost/quality_scale.yaml index 506d69d83fcf..b2f5b9dfb637 100644 --- a/homeassistant/components/ghost/quality_scale.yaml +++ b/homeassistant/components/ghost/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/ghost/strings.json b/homeassistant/components/ghost/strings.json index a9ae0090d3cf..c0de7500dd7b 100644 --- a/homeassistant/components/ghost/strings.json +++ b/homeassistant/components/ghost/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "This Ghost site is already configured." + "already_configured": "This Ghost site is already configured.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "Failed to connect to Ghost. Please check your URL.", @@ -10,6 +11,16 @@ "unknown": "An unexpected error occurred." }, "step": { + "reauth_confirm": { + "data": { + "admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]" + }, + "data_description": { + "admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]" + }, + "description": "Your API key for {title} is invalid. [Create a new integration key]({docs_url}) to reauthenticate.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "admin_api_key": "Admin API key", diff --git a/tests/components/ghost/test_config_flow.py b/tests/components/ghost/test_config_flow.py index 8ba7351be289..cb2e333f0081 100644 --- a/tests/components/ghost/test_config_flow.py +++ b/tests/components/ghost/test_config_flow.py @@ -16,6 +16,10 @@ from homeassistant.data_entry_flow import FlowResultType from .conftest import API_KEY, API_URL, SITE_UUID +from tests.common import MockConfigEntry + +NEW_API_KEY = "new_key_id:new_key_secret" + @pytest.mark.usefixtures("mock_setup_entry") async def test_form_user(hass: HomeAssistant, mock_ghost_api: AsyncMock) -> None: @@ -138,3 +142,88 @@ async def test_form_errors_can_recover( CONF_API_URL: API_URL, CONF_ADMIN_API_KEY: API_KEY, } + + +@pytest.mark.usefixtures("mock_ghost_api", "mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + 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_ADMIN_API_KEY: NEW_API_KEY}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_ADMIN_API_KEY] == NEW_API_KEY + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GhostAuthError("Invalid API key"), "invalid_auth"), + (GhostConnectionError("Connection failed"), "cannot_connect"), + (RuntimeError("Unexpected"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_errors_can_recover( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ghost_api: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test reauth flow errors and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + mock_ghost_api.get_site.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADMIN_API_KEY: NEW_API_KEY}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_ghost_api.get_site.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADMIN_API_KEY: NEW_API_KEY}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_ADMIN_API_KEY] == NEW_API_KEY + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_reauth_flow_invalid_api_key_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with invalid API key format.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADMIN_API_KEY: "invalid-no-colon"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_api_key"}