Create issues for reauth flows (#109105)

pull/109217/head
Erik Montnemery 2024-01-31 15:05:52 +01:00 committed by GitHub
parent 71c2460161
commit ffdcdaf43b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 209 additions and 9 deletions

View File

@ -46,6 +46,10 @@
} }
} }
} }
},
"config_entry_reauth": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Reauthentication is needed"
} }
}, },
"system_health": { "system_health": {

View File

@ -29,6 +29,7 @@ from .const import DOMAIN
@callback @callback
def async_setup(hass: HomeAssistant) -> None: def async_setup(hass: HomeAssistant) -> None:
"""Set up the repairs websocket API.""" """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_ignore_issue)
websocket_api.async_register_command(hass, ws_list_issues) 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"])) 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 @callback
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {

View File

@ -23,15 +23,23 @@ from typing import TYPE_CHECKING, Any, Self, TypeVar, cast
from . import data_entry_flow, loader from . import data_entry_flow, loader
from .components import persistent_notification from .components import persistent_notification
from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform
from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback from .core import (
from .data_entry_flow import FlowResult CALLBACK_TYPE,
DOMAIN as HA_DOMAIN,
CoreState,
Event,
HassJob,
HomeAssistant,
callback,
)
from .data_entry_flow import FLOW_NOT_COMPLETE_STEPS, FlowResult
from .exceptions import ( from .exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
ConfigEntryError, ConfigEntryError,
ConfigEntryNotReady, ConfigEntryNotReady,
HomeAssistantError, 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.debounce import Debouncer
from .helpers.dispatcher import async_dispatcher_send from .helpers.dispatcher import async_dispatcher_send
from .helpers.event import ( from .helpers.event import (
@ -793,7 +801,7 @@ class ConfigEntry:
if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})):
# Reauth flow already in progress for this entry # Reauth flow already in progress for this entry
return return
await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
self.domain, self.domain,
context={ context={
"source": SOURCE_REAUTH, "source": SOURCE_REAUTH,
@ -804,6 +812,21 @@ class ConfigEntry:
| (context or {}), | (context or {}),
data=self.data | (data 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 @callback
def async_get_active_flows( 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): if not self._async_has_other_discovery_flows(flow.flow_id):
persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_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: if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return result return result
@ -1230,12 +1261,15 @@ class ConfigEntries:
ent_reg.async_clear_config_entry(entry_id) ent_reg.async_clear_config_entry(entry_id)
# If the configuration entry is removed during reauth, it should # 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( for progress_flow in self.hass.config_entries.flow.async_progress_by_handler(
entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH}
): ):
if "flow_id" in progress_flow: if "flow_id" in progress_flow:
self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) 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 # 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 # it so that a user is able to immediately start configuring it. We do this by

View File

@ -79,6 +79,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None:
"homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth",
return_value={ return_value={
"type": data_entry_flow.FlowResultType.FORM, "type": data_entry_flow.FlowResultType.FORM,
"flow_id": "mock_flow",
"step_id": "reauth_confirm", "step_id": "reauth_confirm",
}, },
) as mock_async_step_reauth: ) as mock_async_step_reauth:

View File

@ -25,6 +25,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None:
"homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth",
return_value={ return_value={
"type": data_entry_flow.FlowResultType.FORM, "type": data_entry_flow.FlowResultType.FORM,
"flow_id": "mock_flow",
"step_id": "reauth_confirm", "step_id": "reauth_confirm",
}, },
) as mock_async_step_reauth: ) as mock_async_step_reauth:

View File

@ -579,3 +579,78 @@ async def test_fix_issue_aborted(
assert msg["success"] assert msg["success"]
assert len(msg["result"]["issues"]) == 1 assert len(msg["result"]["issues"]) == 1
assert msg["result"]["issues"][0] == first_issue 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",
}

View File

@ -52,6 +52,7 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None:
"homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth",
return_value={ return_value={
"type": data_entry_flow.FlowResultType.FORM, "type": data_entry_flow.FlowResultType.FORM,
"flow_id": "mock_flow",
"step_id": "reauth_confirm", "step_id": "reauth_confirm",
}, },
) as mock_async_step_reauth: ) as mock_async_step_reauth:

View File

@ -6,7 +6,7 @@ from collections.abc import Generator
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -19,7 +19,13 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP, 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.data_entry_flow import BaseServiceInfo, FlowResult, FlowResultType
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
@ -27,7 +33,7 @@ from homeassistant.exceptions import (
ConfigEntryNotReady, ConfigEntryNotReady,
HomeAssistantError, 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 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): async def async_step_reauth(self, data):
"""Mock Reauth.""" """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( with patch.dict(
config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} 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 flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH
assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR 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) await manager.async_remove(entry.entry_id)
flows = hass.config_entries.flow.async_progress_by_handler("test") flows = hass.config_entries.flow.async_progress_by_handler("test")
assert len(flows) == 0 assert len(flows) == 0
assert not issue_registry.async_get_issue(HA_DOMAIN, issue_id)
async def test_remove_entry_handles_callback_error( 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 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: async def test_discovery_notification_not_created(hass: HomeAssistant) -> None:
"""Test that we not create a notification when discovery is aborted.""" """Test that we not create a notification when discovery is aborted."""
mock_integration(hass, MockModule("test")) mock_integration(hass, MockModule("test"))