Add reauthentication flow to the Imou integration (#180975)

This commit is contained in:
Imou-OpenPlatform
2026-09-01 10:02:46 +02:00
committed by GitHub
parent 6540e013f3
commit 45c2434230
4 changed files with 177 additions and 17 deletions
+72 -15
View File
@@ -1,5 +1,6 @@
"""Config flow for Imou."""
from collections.abc import Mapping
import logging
from typing import Any, override
@@ -17,12 +18,26 @@ from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN
_LOGGER = logging.getLogger(__name__)
REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_APP_SECRET): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Imou integration."""
@@ -30,6 +45,27 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate credentials and close the temporary client."""
errors: dict[str, str] = {}
api_client = ImouOpenApiClient(
user_input[CONF_APP_ID],
user_input[CONF_APP_SECRET],
API_URLS[user_input[CONF_API_URL]],
)
try:
await api_client.async_get_token()
except InvalidAppIdOrSecretException:
errors["base"] = "invalid_auth"
except ConnectFailedException, RequestFailedException:
errors["base"] = "cannot_connect"
except ImouException as exception:
_LOGGER.debug("Imou error during config flow: %s", exception)
errors["base"] = "unknown"
finally:
await api_client.async_close()
return errors
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -39,21 +75,7 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_APP_ID])
self._abort_if_unique_id_configured()
api_client = ImouOpenApiClient(
user_input[CONF_APP_ID],
user_input[CONF_APP_SECRET],
API_URLS[user_input[CONF_API_URL]],
)
try:
await api_client.async_get_token()
except InvalidAppIdOrSecretException:
errors["base"] = "invalid_auth"
except ConnectFailedException, RequestFailedException:
errors["base"] = "cannot_connect"
except ImouException as exception:
_LOGGER.debug("Imou error during config flow: %s", exception)
errors["base"] = "unknown"
else:
if not (errors := await self._validate_input(user_input)):
return self.async_create_entry(
title="Imou",
data={
@@ -79,3 +101,38 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
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 App secret."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
if not (
errors := await self._validate_input(
{
CONF_APP_ID: reauth_entry.data[CONF_APP_ID],
CONF_APP_SECRET: user_input[CONF_APP_SECRET],
CONF_API_URL: reauth_entry.data[CONF_API_URL],
}
)
):
await self.async_set_unique_id(reauth_entry.data[CONF_APP_ID])
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_APP_SECRET: user_input[CONF_APP_SECRET]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=REAUTH_SCHEMA,
description_placeholders={"app_id": reauth_entry.data[CONF_APP_ID]},
errors=errors,
)
@@ -40,7 +40,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: todo
# Gold
+13 -1
View File
@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The App ID does not match the previously configured account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,6 +12,16 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"app_secret": "[%key:component::imou::config::step::user::data::app_secret%]"
},
"data_description": {
"app_secret": "[%key:component::imou::config::step::user::data_description::app_secret%]"
},
"description": "Enter a new App secret for {app_id}.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_url": "Server region",
+91
View File
@@ -31,6 +31,8 @@ DHCP_DISCOVERY = DhcpServiceInfo(
macaddress="1c4d895f7a29",
)
NEW_APP_SECRET = "new_app_secret"
async def test_user_flow_success(
hass: HomeAssistant,
@@ -262,3 +264,92 @@ async def test_dhcp_discovery_invalid_auth(
assert result["data"] == USER_INPUT
assert result["result"].unique_id == USER_INPUT[CONF_APP_ID]
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_flow_success(
hass: HomeAssistant,
mock_imou_openapi_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Reauth updates the App secret and reloads the entry."""
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"],
user_input={CONF_APP_SECRET: NEW_APP_SECRET},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_APP_SECRET] == NEW_APP_SECRET
mock_imou_openapi_client.async_close.assert_awaited_once()
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(ConnectFailedException("fail"), "cannot_connect"),
(RequestFailedException("fail"), "cannot_connect"),
(InvalidAppIdOrSecretException("fail"), "invalid_auth"),
(ImouException("fail"), "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_flow_exception_then_recover(
hass: HomeAssistant,
mock_imou_openapi_client: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
expected_error: str,
) -> None:
"""Errors map to stable keys; clearing the failure allows completing reauth."""
mock_config_entry.add_to_hass(hass)
mock_imou_openapi_client.async_get_token.side_effect = side_effect
result = await mock_config_entry.start_reauth_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_APP_SECRET: NEW_APP_SECRET},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"]["base"] == expected_error
mock_imou_openapi_client.async_get_token.reset_mock(side_effect=True)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_APP_SECRET: NEW_APP_SECRET},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_APP_SECRET] == NEW_APP_SECRET
assert mock_imou_openapi_client.async_close.await_count == 2
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth_unique_id_mismatch(
hass: HomeAssistant,
mock_imou_openapi_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Reauth aborts when the unique ID does not match the existing entry."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(mock_config_entry, unique_id="other-app-id")
result = await mock_config_entry.start_reauth_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_APP_SECRET: NEW_APP_SECRET},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"