Add reauth flow for Smlight (#124418)

* Add reauth flow for smlight integration

* add strings for reauth

* trigger reauth flow on authentication errors

* Add tests for reauth flow

* test for update failed on auth error

* restore name title placeholder

* raise config entry error to trigger reauth

* Add test for reauth triggered at startup

---------

Co-authored-by: Tim Lunn <tim@feathertop.org>
This commit is contained in:
TimL 2024-09-05 19:02:05 +10:00 committed by GitHub
parent b5831344a0
commit 511ecf98d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 215 additions and 7 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from pysmlight import Api2 from pysmlight import Api2
@ -14,6 +15,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from . import SmConfigEntry
from .const import DOMAIN from .const import DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema( STEP_USER_DATA_SCHEMA = vol.Schema(
@ -37,6 +39,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow.""" """Initialize the config flow."""
self.client: Api2 self.client: Api2
self.host: str | None = None self.host: str | None = None
self._reauth_entry: SmConfigEntry | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -127,6 +130,52 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth when API Authentication failed."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
host = entry_data[CONF_HOST]
self.context["title_placeholders"] = {
"host": host,
"name": entry_data.get(CONF_USERNAME, "unknown"),
}
self.client = Api2(host, session=async_get_clientsession(self.hass))
self.host = host
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
errors = {}
if user_input is not None:
try:
await self.client.authenticate(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except SmlightAuthError:
errors["base"] = "invalid_auth"
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
else:
assert self._reauth_entry is not None
return self.async_update_reload_and_abort(
self._reauth_entry, data={**user_input, CONF_HOST: self.host}
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_AUTH_DATA_SCHEMA,
description_placeholders=self.context["title_placeholders"],
errors=errors,
)
async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool:
"""Check if auth required and attempt to authenticate.""" """Check if auth required and attempt to authenticate."""
if await self.client.check_auth_needed(): if await self.client.check_auth_needed():

View file

@ -8,7 +8,7 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -54,8 +54,10 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
self.config_entry.data[CONF_PASSWORD], self.config_entry.data[CONF_PASSWORD],
) )
except SmlightAuthError as err: except SmlightAuthError as err:
LOGGER.error("Failed to authenticate: %s", err) raise ConfigEntryAuthFailed from err
raise ConfigEntryError from err else:
# Auth required but no credentials available
raise ConfigEntryAuthFailed
info = await self.client.get_info() info = await self.client.get_info()
self.unique_id = format_mac(info.MAC) self.unique_id = format_mac(info.MAC)
@ -67,5 +69,8 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
sensors=await self.client.get_sensors(), sensors=await self.client.get_sensors(),
info=await self.client.get_info(), info=await self.client.get_info(),
) )
except SmlightAuthError as err:
raise ConfigEntryAuthFailed from err
except SmlightConnectionError as err: except SmlightConnectionError as err:
raise UpdateFailed(err) from err raise UpdateFailed(err) from err

View file

@ -17,6 +17,14 @@
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
}, },
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Please enter the correct username and password for host: {host}",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"confirm_discovery": { "confirm_discovery": {
"description": "Do you want to set up SMLIGHT at {host}?" "description": "Do you want to set up SMLIGHT at {host}?"
} }
@ -27,7 +35,10 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_failed": "[%key:common::config_flow::error::invalid_auth%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"entity": { "entity": {

View file

@ -32,6 +32,18 @@ def mock_config_entry() -> MockConfigEntry:
) )
@pytest.fixture
def mock_config_entry_host() -> MockConfigEntry:
"""Return the default mocked config entry, no credentials."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: MOCK_HOST,
},
unique_id="aa:bb:cc:dd:ee:ff",
)
@pytest.fixture @pytest.fixture
def platforms() -> list[Platform]: def platforms() -> list[Platform]:
"""Platforms, which should be loaded during the test.""" """Platforms, which should be loaded during the test."""

View file

@ -363,3 +363,116 @@ async def test_zeroconf_legacy_mac(
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 2 assert len(mock_smlight_client.get_info.mock_calls) == 2
async def test_reauth_flow(
hass: HomeAssistant,
mock_smlight_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test reauth flow completes successfully."""
mock_smlight_client.check_auth_needed.return_value = True
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"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
assert mock_config_entry.data == {
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
CONF_HOST: MOCK_HOST,
}
assert len(mock_smlight_client.authenticate.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
async def test_reauth_auth_error(
hass: HomeAssistant,
mock_smlight_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test reauth flow with authentication error."""
mock_smlight_client.check_auth_needed.return_value = True
mock_smlight_client.authenticate.side_effect = SmlightAuthError
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"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: "test-bad",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "reauth_confirm"
mock_smlight_client.authenticate.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert mock_config_entry.data == {
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
CONF_HOST: MOCK_HOST,
}
assert len(mock_smlight_client.authenticate.mock_calls) == 2
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
async def test_reauth_connect_error(
hass: HomeAssistant,
mock_smlight_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with error."""
mock_smlight_client.check_auth_needed.return_value = True
mock_smlight_client.authenticate.side_effect = SmlightConnectionError
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"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "cannot_connect"
assert len(mock_smlight_client.authenticate.mock_calls) == 1

View file

@ -3,7 +3,7 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, SmlightError
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -55,19 +55,37 @@ async def test_async_setup_auth_failed(
assert entry.state is ConfigEntryState.NOT_LOADED assert entry.state is ConfigEntryState.NOT_LOADED
async def test_async_setup_missing_credentials(
hass: HomeAssistant,
mock_config_entry_host: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test we trigger reauth when credentials are missing."""
mock_smlight_client.check_auth_needed.return_value = True
await setup_integration(hass, mock_config_entry_host)
progress = hass.config_entries.flow.async_progress()
assert len(progress) == 1
assert progress[0]["step_id"] == "reauth_confirm"
assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
@pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError])
async def test_update_failed( async def test_update_failed(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock, mock_smlight_client: MagicMock,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
error: SmlightError,
) -> None: ) -> None:
"""Test update failed due to connection error.""" """Test update failed due to error."""
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
entity = hass.states.get("sensor.mock_title_core_chip_temp") entity = hass.states.get("sensor.mock_title_core_chip_temp")
assert entity.state is not STATE_UNAVAILABLE assert entity.state is not STATE_UNAVAILABLE
mock_smlight_client.get_info.side_effect = SmlightConnectionError mock_smlight_client.get_info.side_effect = error
freezer.tick(SCAN_INTERVAL) freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)