diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 862ac12cefb..e2a6fc1c9e7 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -46,6 +46,10 @@ } } } + }, + "config_entry_reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Reauthentication is needed" } }, "system_health": { diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 73ef4d624ec..78a3c10bbe4 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -29,6 +29,7 @@ from .const import DOMAIN @callback def async_setup(hass: HomeAssistant) -> None: """Set up the repairs websocket API.""" + websocket_api.async_register_command(hass, ws_get_issue_data) websocket_api.async_register_command(hass, ws_ignore_issue) websocket_api.async_register_command(hass, ws_list_issues) @@ -36,6 +37,29 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(RepairsFlowResourceView(hass.data[DOMAIN]["flow_manager"])) +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "repairs/get_issue_data", + vol.Required("domain"): str, + vol.Required("issue_id"): str, + } +) +def ws_get_issue_data( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Fix an issue.""" + issue_registry = async_get_issue_registry(hass) + if not (issue := issue_registry.async_get_issue(msg["domain"], msg["issue_id"])): + connection.send_error( + msg["id"], + "unknown_issue", + f"Issue '{msg['issue_id']}' not found", + ) + return + connection.send_result(msg["id"], {"issue_data": issue.data}) + + @callback @websocket_api.websocket_command( { diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d9cfbd08886..b0a8f952b1b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -23,15 +23,23 @@ from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from . import data_entry_flow, loader from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform -from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback -from .data_entry_flow import FlowResult +from .core import ( + CALLBACK_TYPE, + DOMAIN as HA_DOMAIN, + CoreState, + Event, + HassJob, + HomeAssistant, + callback, +) +from .data_entry_flow import FLOW_NOT_COMPLETE_STEPS, FlowResult from .exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, HomeAssistantError, ) -from .helpers import device_registry, entity_registry, storage +from .helpers import device_registry, entity_registry, issue_registry as ir, storage from .helpers.debounce import Debouncer from .helpers.dispatcher import async_dispatcher_send from .helpers.event import ( @@ -793,7 +801,7 @@ class ConfigEntry: if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -804,6 +812,21 @@ class ConfigEntry: | (context or {}), data=self.data | (data or {}), ) + if result["type"] not in FLOW_NOT_COMPLETE_STEPS: + return + + # Create an issue, there's no need to hold the lock when doing that + issue_id = f"config_entry_reauth_{self.domain}_{self.entry_id}" + ir.async_create_issue( + hass, + HA_DOMAIN, + issue_id, + data={"flow_id": result["flow_id"]}, + is_fixable=False, + issue_domain=self.domain, + severity=ir.IssueSeverity.ERROR, + translation_key="config_entry_reauth", + ) @callback def async_get_active_flows( @@ -981,6 +1004,14 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): if not self._async_has_other_discovery_flows(flow.flow_id): persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None and ( + entry := self.config_entries.async_get_entry(entry_id) + ) is not None: + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HA_DOMAIN, issue_id) + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result @@ -1230,12 +1261,15 @@ class ConfigEntries: ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should - # abort any reauth flow that is active for the removed entry. + # abort any reauth flow that is active for the removed entry and + # linked issues. for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} ): if "flow_id" in progress_flow: self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HA_DOMAIN, issue_id) # After we have fully removed an "ignore" config entry we can try and rediscover # it so that a user is able to immediately start configuring it. We do this by diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index d208b6302bc..ae7ed51e086 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -79,6 +79,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 1430eca3a26..e16a721f5dc 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -25,6 +25,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 1f68c9a28d3..0cf6b22dc0c 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -579,3 +579,78 @@ async def test_fix_issue_aborted( assert msg["success"] assert len(msg["result"]["issues"]) == 1 assert msg["result"]["issues"][0] == first_issue + + +@pytest.mark.freeze_time("2022-07-19 07:53:05") +async def test_get_issue_data(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get issue data.""" + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + issues = [ + { + "breaks_in_ha_version": "2022.9", + "data": None, + "domain": "test", + "is_fixable": True, + "issue_id": "issue_1", + "issue_domain": None, + "learn_more_url": "https://theuselessweb.com", + "severity": "error", + "translation_key": "abc_123", + "translation_placeholders": {"abc": "123"}, + }, + { + "breaks_in_ha_version": "2022.8", + "data": {"key": "value"}, + "domain": "test", + "is_fixable": False, + "issue_id": "issue_2", + "issue_domain": None, + "learn_more_url": "https://theuselessweb.com/abc", + "severity": "other", + "translation_key": "even_worse", + "translation_placeholders": {"def": "456"}, + }, + ] + + for issue in issues: + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + data=issue["data"], + is_fixable=issue["is_fixable"], + is_persistent=False, + learn_more_url=issue["learn_more_url"], + severity=issue["severity"], + translation_key=issue["translation_key"], + translation_placeholders=issue["translation_placeholders"], + ) + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_1"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issue_data": None} + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_2"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issue_data": {"key": "value"}} + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "unknown"} + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "unknown_issue", + "message": "Issue 'unknown' not found", + } diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 91556f459ba..8f66044a66b 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -52,6 +52,7 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e9e1437c06c..1c67534d5df 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6,7 +6,7 @@ from collections.abc import Generator from datetime import timedelta import logging from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -19,7 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, + CoreState, + Event, + HomeAssistant, + callback, +) from homeassistant.data_entry_flow import BaseServiceInfo, FlowResult, FlowResultType from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -27,7 +33,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -59,7 +65,13 @@ def mock_handlers() -> Generator[None, None, None]: async def async_step_reauth(self, data): """Mock Reauth.""" - return self.async_show_form(step_id="reauth") + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Test reauth confirm step.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return self.async_abort(reason="test") with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} @@ -425,10 +437,15 @@ async def test_remove_entry_cancels_reauth( assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + issue_registry = ir.async_get(hass) + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + assert issue_registry.async_get_issue(HA_DOMAIN, issue_id) + await manager.async_remove(entry.entry_id) flows = hass.config_entries.flow.async_progress_by_handler("test") assert len(flows) == 0 + assert not issue_registry.async_get_issue(HA_DOMAIN, issue_id) async def test_remove_entry_handles_callback_error( @@ -911,6 +928,49 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: assert "config_entry_reconfigure" not in notifications +async def test_reauth_issue(hass: HomeAssistant) -> None: + """Test that we create/delete an issue when source is reauth.""" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 0 + + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + entry.add_to_hass(hass) + await entry.async_setup(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 1 + + assert len(issue_registry.issues) == 1 + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + issue = issue_registry.async_get_issue(HA_DOMAIN, issue_id) + assert issue == ir.IssueEntry( + active=True, + breaks_in_ha_version=None, + created=ANY, + data={"flow_id": flows[0]["flow_id"]}, + dismissed_version=None, + domain=HA_DOMAIN, + is_fixable=False, + is_persistent=False, + issue_domain="test", + issue_id=issue_id, + learn_more_url=None, + severity=ir.IssueSeverity.ERROR, + translation_key="config_entry_reauth", + translation_placeholders=None, + ) + + result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: """Test that we not create a notification when discovery is aborted.""" mock_integration(hass, MockModule("test"))