diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..7ab1ad1 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,12 @@ +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.set] +ANTHROPIC_BASE_URL = "http://192.168.31.109:8045" +ANTHROPIC_AUTH_TOKEN = "sk-fa21bd05103d42878e2e2b796d6198eb" +ANTHROPIC_MODEL = "gemini-3-pro-high" +ANTHROPIC_DEFAULT_SONNET_MODEL = "gemini-3-pro-high" +ANTHROPIC_SMALL_FAST_MODEL = "gemini-3-flash" +ANTHROPIC_DEFAULT_HAIKU_MODEL = "gemini-3-flash" +DISABLE_NON_ESSENTIAL_MODEL_CALLS = "1" +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" diff --git a/custom_components/domru/__init__.py b/custom_components/domru/__init__.py index abba71b..f8b3b14 100644 --- a/custom_components/domru/__init__.py +++ b/custom_components/domru/__init__.py @@ -14,7 +14,7 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -34,9 +34,6 @@ DomruApiClientError, ) from .const import ( - CONF_ACCESS_TOKEN, - CONF_OPERATOR_ID, - CONF_REFRESH_TOKEN, CONF_SIP_ENABLED, CONF_SIP_HOST_IP, CONF_SIP_LOCAL_IP, @@ -52,6 +49,7 @@ from .coordinator import DomruDataUpdateCoordinator from .data import DomruData from .door import async_open_door +from .entry_setup import async_create_client_and_load_data from .fcm import DomruFcmListener from .media import async_setup_camera_audio from .sip import DomruSipClient, SipAccount @@ -77,23 +75,13 @@ async def async_setup_entry( entry: DomruConfigEntry, ) -> bool: """Set up this integration using UI.""" - client = DomruApiClient( - username=entry.data.get(CONF_USERNAME), - password=entry.data.get(CONF_PASSWORD), - session=async_get_clientsession(hass), - access_token=( - entry.data.get(CONF_ACCESS_TOKEN) or entry.data.get(CONF_REFRESH_TOKEN) - ), - refresh_token=entry.data.get(CONF_REFRESH_TOKEN), - operator_id=entry.data.get(CONF_OPERATOR_ID), + # Authenticate and load IDs/FCM targets with HA-native retry and reauth errors. + client, initial_data = await async_create_client_and_load_data( + hass, + entry, + async_get_clientsession(hass), ) - # Authenticate first - await client.async_authenticate() - - # Load initial data to set IDs and all FCM access-control targets. - initial_data = await client.async_get_data() - # Enable Home Assistant's optional WebRTC provider before camera entities load. await async_setup_camera_audio(hass) diff --git a/custom_components/domru/api.py b/custom_components/domru/api.py index 06a27ca..a13e9e0 100644 --- a/custom_components/domru/api.py +++ b/custom_components/domru/api.py @@ -9,12 +9,15 @@ import uuid from datetime import UTC, datetime from json.decoder import JSONDecodeError -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import quote, urljoin import aiohttp from aiohttp.client_exceptions import ClientConnectorError, ContentTypeError +if TYPE_CHECKING: + from collections.abc import Callable + try: from async_timeout import timeout as async_timeout except ModuleNotFoundError: @@ -155,6 +158,7 @@ def __init__( access_token: str | None = None, refresh_token: str | None = None, operator_id: str | int | None = None, + on_auth_update: Callable[[str, str, str | int], None] | None = None, ) -> None: """Initialize the API client.""" self._username = username @@ -163,6 +167,7 @@ def __init__( self._access_token = access_token self._refresh_token = refresh_token self._operator_id = operator_id + self._on_auth_update = on_auth_update self._place_id: str | int | None = None self._access_control_id: str | int | None = None # Hash parameters from go-impl/pkg/auth/password.go @@ -219,15 +224,14 @@ async def _set_access_token(self) -> None: if self._access_token is not None: return + refresh_auth_error: DomruApiClientAuthenticationError | None = None if self._refresh_token is not None and self._operator_id is not None: # Try to refresh token first try: await self._refresh_access_token() - except ( - DomruApiClientError, - DomruApiClientCommunicationError, - ): # pylint: disable=broad-except - _LOGGER.debug("Failed to refresh access token") + except DomruApiClientAuthenticationError as exception: + refresh_auth_error = exception + _LOGGER.debug("Stored session can no longer be refreshed") else: return @@ -265,6 +269,8 @@ async def _set_access_token(self) -> None: if not self._access_token: msg = "No access token in response" raise DomruApiClientAuthenticationError(msg) + elif refresh_auth_error is not None: + raise refresh_auth_error else: msg = "No credentials provided" raise DomruApiClientAuthenticationError(msg) @@ -284,6 +290,9 @@ async def _refresh_access_token(self) -> None: method="GET", headers=headers, authenticated=False, + status_messages={ + self.HTTP_UNAUTHORIZED: "Stored session has expired.", + }, ) auth_data = _auth_response_data(result) @@ -295,6 +304,22 @@ async def _refresh_access_token(self) -> None: msg = "No access token in refresh response" raise DomruApiClientAuthenticationError(msg) + self._notify_auth_update() + + def _notify_auth_update(self) -> None: + """Notify the integration when the API rotates stored credentials.""" + if ( + self._on_auth_update is not None + and self._access_token + and self._refresh_token + and self._operator_id is not None + ): + self._on_auth_update( + self._access_token, + self._refresh_token, + self._operator_id, + ) + async def async_get_phone_accounts(self, phone: str) -> list[dict[str, Any]]: """Get accounts available for a phone number.""" escaped_phone = quote(phone, safe="") @@ -395,15 +420,9 @@ async def async_get_data(self) -> dict[str, Any]: "events": [], } - # Get subscriber places - try: - await self._async_add_places_and_access_controls(data) - except ( - DomruApiClientError, - DomruApiClientCommunicationError, - TimeoutError, - ): # pylint: disable=broad-except - _LOGGER.debug("Failed to get subscriber places") + # Subscriber places are the primary discovery call. Propagate failures so + # Home Assistant can distinguish reauthentication from a temporary outage. + await self._async_add_places_and_access_controls(data) # Get cameras try: @@ -989,6 +1008,19 @@ def _handle_authentication_error(self, message: str) -> None: """Handle a request-specific authentication error.""" raise DomruApiClientAuthenticationError(message) + async def _async_refresh_after_unauthorized( + self, + *, + token_refreshed: bool, + ) -> None: + """Refresh once after a 401 or raise when the replay was also rejected.""" + if token_refreshed: + self._handle_authentication_error( + "Unauthorized after refreshing access token" + ) + self._access_token = None + await self._set_access_token() + async def _parse_response( self, response: aiohttp.ClientResponse, @@ -1025,6 +1057,7 @@ async def _api_wrapper( """Make an API request with automatic token refresh on 401.""" allowed_statuses = success_statuses or (self.HTTP_OK, self.HTTP_CREATED) parse_statuses = (*allowed_statuses, *(status_messages or {})) + token_refreshed = False while True: try: headers_to_use = headers or ( @@ -1047,7 +1080,10 @@ async def _api_wrapper( # Handle 401 - try to refresh token and retry if response.status == self.HTTP_UNAUTHORIZED and authenticated: - await self._set_access_token() + await self._async_refresh_after_unauthorized( + token_refreshed=token_refreshed + ) + token_refreshed = True continue # Retry the request with new token json_response = await self._parse_response( diff --git a/custom_components/domru/config_flow.py b/custom_components/domru/config_flow.py index 3dbb8e0..44e68f5 100644 --- a/custom_components/domru/config_flow.py +++ b/custom_components/domru/config_flow.py @@ -92,6 +92,29 @@ async def async_step_user( ), ) + async def async_step_reauth( + self, + _entry_data: dict, + ) -> config_entries.ConfigFlowResult: + """Start reauthentication for an entry with expired credentials.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, + user_input: dict | None = None, + ) -> config_entries.ConfigFlowResult: + """Confirm reauthentication before collecting fresh credentials.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + + entry = self._get_reauth_entry() + if entry.data.get(CONF_AUTH_METHOD) == AUTH_METHOD_PHONE: + return await self.async_step_phone() + return await self.async_step_password() + async def async_step_password( self, user_input: dict | None = None, @@ -114,18 +137,12 @@ async def async_step_password( LOGGER.exception(exception) _errors["base"] = "unknown" else: - await self.async_set_unique_id( - ## Do NOT use this in production code - ## The unique_id should never be something that can change - ## https://developers.home-assistant.io/docs/config_entries_config_flow_handler#unique-ids - unique_id=slugify(user_input[CONF_USERNAME]) - ) - self._abort_if_unique_id_configured() data = dict(user_input) data[CONF_AUTH_METHOD] = AUTH_METHOD_PASSWORD - return self.async_create_entry( + return await self._async_finish_entry( title=user_input[CONF_USERNAME], data=data, + unique_id=slugify(user_input[CONF_USERNAME]), ) return self.async_show_form( @@ -291,9 +308,7 @@ async def async_step_sms( _errors["base"] = ERROR_API else: account_id = self._selected_account.get("accountId", self._phone) - await self.async_set_unique_id(slugify(str(account_id))) - self._abort_if_unique_id_configured() - return self.async_create_entry( + return await self._async_finish_entry( title=_account_label(self._selected_account), data={ CONF_AUTH_METHOD: AUTH_METHOD_PHONE, @@ -303,6 +318,7 @@ async def async_step_sms( CONF_REFRESH_TOKEN: refresh_token, CONF_OPERATOR_ID: operator_id, }, + unique_id=slugify(str(account_id)), ) return self.async_show_form( @@ -339,6 +355,31 @@ def _create_client(self) -> DomruApiClient: session=async_create_clientsession(self.hass), ) + async def _async_finish_entry( + self, + *, + title: str, + data: dict, + unique_id: str, + ) -> config_entries.ConfigFlowResult: + """Create a new entry or replace credentials during reauthentication.""" + await self.async_set_unique_id(unique_id) + if self.source == config_entries.SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + reauth_entry = self._get_reauth_entry() + if update_and_abort := getattr(self, "async_update_and_abort", None): + return update_and_abort( + reauth_entry, + data_updates=data, + ) + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=data, + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=data) + async def _async_request_phone_confirmation(self) -> tuple[str, str] | None: """Request SMS confirmation and return a config flow error key on failure.""" if self._phone is None or self._selected_account is None: diff --git a/custom_components/domru/entry_setup.py b/custom_components/domru/entry_setup.py new file mode 100644 index 0000000..b4f90d6 --- /dev/null +++ b/custom_components/domru/entry_setup.py @@ -0,0 +1,70 @@ +"""Config-entry setup boundary for Dom.ru authentication and API failures.""" + +from __future__ import annotations + +from functools import partial +from typing import Any + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .api import ( + DomruApiClient, + DomruApiClientAuthenticationError, + DomruApiClientCommunicationError, + DomruApiClientError, +) +from .const import CONF_ACCESS_TOKEN, CONF_OPERATOR_ID, CONF_REFRESH_TOKEN + + +def persist_auth_update( + hass: Any, + entry: Any, + access_token: str, + refresh_token: str, + operator_id: str | int, +) -> None: + """Persist rotated credentials without discarding other entry data.""" + data = { + **entry.data, + CONF_ACCESS_TOKEN: access_token, + CONF_REFRESH_TOKEN: refresh_token, + CONF_OPERATOR_ID: operator_id, + } + if data != entry.data: + hass.config_entries.async_update_entry(entry, data=data) + + +async def async_load_initial_data(client: Any) -> dict[str, Any]: + """Authenticate and load required data with Home Assistant failure semantics.""" + try: + await client.async_authenticate() + return await client.async_get_data() + except DomruApiClientAuthenticationError as exception: + raise ConfigEntryAuthFailed(exception) from exception + except ( + DomruApiClientCommunicationError, + DomruApiClientError, + ) as exception: + raise ConfigEntryNotReady(str(exception)) from exception + + +async def async_create_client_and_load_data( + hass: Any, + entry: Any, + session: Any, +) -> tuple[DomruApiClient, dict[str, Any]]: + """Create a persistence-aware API client and load required initial data.""" + client = DomruApiClient( + username=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + session=session, + access_token=( + entry.data.get(CONF_ACCESS_TOKEN) or entry.data.get(CONF_REFRESH_TOKEN) + ), + refresh_token=entry.data.get(CONF_REFRESH_TOKEN), + operator_id=entry.data.get(CONF_OPERATOR_ID), + on_auth_update=partial(persist_auth_update, hass, entry), + ) + initial_data = await async_load_initial_data(client) + return client, initial_data diff --git a/custom_components/domru/translations/en.json b/custom_components/domru/translations/en.json index d635a7c..623a6ab 100644 --- a/custom_components/domru/translations/en.json +++ b/custom_components/domru/translations/en.json @@ -32,6 +32,9 @@ "sms_code": "SMS code" } }, + "reauth_confirm": { + "description": "Your Dom.ru session has expired. Continue to sign in again." + }, "init": { "description": "Configure camera stream options", "data": { @@ -47,7 +50,8 @@ "unknown": "Unknown error occurred." }, "abort": { - "already_configured": "This entry is already configured." + "already_configured": "This entry is already configured.", + "reauth_successful": "Dom.ru authentication was updated successfully." } }, "options": { diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index 138063d..d46944b 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -1,4 +1,4 @@ -# ruff: noqa: D102,D107,EM102,TRY003,PT009,S106 +# ruff: noqa: D102,D107,EM102,PT009,PT027,S106,TRY003 """Tests for Dom.ru API endpoint selection.""" from __future__ import annotations @@ -159,6 +159,14 @@ def test_async_get_data_fetches_access_controls_per_place(self) -> None: client.requests[1]["url"], ) + def test_async_get_data_propagates_primary_discovery_failure(self) -> None: + client = CapturingClient( + responses=[api_module.DomruApiClientCommunicationError("offline")] + ) + + with self.assertRaises(api_module.DomruApiClientCommunicationError): + asyncio.run(client.async_get_data()) + def test_get_cameras_prefers_place_scoped_endpoint(self) -> None: client = CapturingClient(responses=[{"data": [{"id": "camera-1"}]}]) client.set_ids(place_id="place-1") diff --git a/tests/test_api_phone_login.py b/tests/test_api_phone_login.py index 91712a0..23de854 100644 --- a/tests/test_api_phone_login.py +++ b/tests/test_api_phone_login.py @@ -101,7 +101,7 @@ async def text(self) -> str: class FakeSession: """Capture outgoing API requests and return queued responses.""" - def __init__(self, *responses: FakeResponse) -> None: + def __init__(self, *responses: FakeResponse | Exception) -> None: self.responses = list(responses) self.requests: list[dict[str, Any]] = [] @@ -121,7 +121,10 @@ async def request( "headers": headers or {}, } ) - return self.responses.pop(0) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response class ApiPhoneLoginTests(unittest.TestCase): @@ -144,6 +147,112 @@ def test_stored_access_token_authentication_makes_no_refresh_request( self.assertEqual(session.requests, []) + def test_unauthorized_request_refreshes_token_before_replaying(self) -> None: + session = FakeSession( + FakeResponse({"error": "expired"}, status=401), + FakeResponse( + { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "operatorId": 321, + } + ), + FakeResponse({"data": [{"place": {"id": "place-1"}}]}), + ) + client = DomruApiClient( + username=None, + password=None, + session=session, + access_token="expired-access", + refresh_token="old-refresh", + operator_id=123, + ) + + places = asyncio.run(client.get_subscriber_places()) + + self.assertEqual(places, [{"place": {"id": "place-1"}}]) + self.assertEqual(len(session.requests), 3) + self.assertTrue(session.requests[1]["url"].endswith("/auth/v2/session/refresh")) + self.assertEqual(session.requests[1]["headers"]["Bearer"], "old-refresh") + self.assertEqual( + session.requests[2]["headers"]["Authorization"], + "Bearer new-access", + ) + + def test_timeout_during_token_refresh_remains_a_temporary_failure(self) -> None: + session = FakeSession( + FakeResponse({"error": "expired"}, status=401), + TimeoutError(), + ) + client = DomruApiClient( + username=None, + password=None, + session=session, + access_token="expired-access", + refresh_token="old-refresh", + operator_id=123, + ) + + try: + asyncio.run(client.get_subscriber_places()) + except api_module.DomruApiClientError as exception: + self.assertIsInstance( + exception, + api_module.DomruApiClientCommunicationError, + ) + else: + self.fail("Token refresh timeout did not fail the request") + + def test_rejected_refresh_token_is_an_authentication_failure(self) -> None: + session = FakeSession( + FakeResponse({"error": "expired"}, status=401), + FakeResponse({"error": "invalid_refresh_token"}, status=401), + ) + client = DomruApiClient( + username=None, + password=None, + session=session, + access_token="expired-access", + refresh_token="expired-refresh", + operator_id=123, + ) + + try: + asyncio.run(client.get_subscriber_places()) + except api_module.DomruApiClientError as exception: + self.assertIsInstance( + exception, + api_module.DomruApiClientAuthenticationError, + ) + else: + self.fail("Rejected refresh token did not fail authentication") + + def test_request_is_not_retried_after_refreshed_token_is_rejected(self) -> None: + session = FakeSession( + FakeResponse({"error": "expired"}, status=401), + FakeResponse( + { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "operatorId": 321, + } + ), + FakeResponse({"error": "still_unauthorized"}, status=401), + ) + client = DomruApiClient( + username=None, + password=None, + session=session, + access_token="expired-access", + refresh_token="old-refresh", + operator_id=123, + ) + + with self.assertRaises(api_module.DomruApiClientAuthenticationError): + asyncio.run(client.get_subscriber_places()) + + self.assertEqual(len(session.requests), 3) + def test_get_phone_accounts_escapes_phone_number(self) -> None: session = FakeSession(FakeResponse([{"accountId": "account-1"}])) client = DomruApiClient(username=None, password=None, session=session) @@ -468,6 +577,35 @@ def test_refresh_token_authentication_accepts_nested_token_response(self) -> Non self.assertEqual(client.refresh_token, "new-refresh") self.assertEqual(client.operator_id, 321) + def test_refresh_notifies_consumer_about_rotated_credentials(self) -> None: + session = FakeSession( + FakeResponse( + { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "operatorId": 321, + } + ) + ) + auth_updates: list[tuple[str, str, int]] = [] + try: + client = DomruApiClient( + username=None, + password=None, + session=session, + refresh_token="old-refresh", + operator_id=123, + on_auth_update=lambda access, refresh, operator: auth_updates.append( + (access, refresh, operator) + ), + ) + except TypeError as exception: + self.fail(f"API client does not expose auth updates: {exception}") + + asyncio.run(client.async_authenticate()) + + self.assertEqual(auth_updates, [("new-access", "new-refresh", 321)]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config_flow_compat.py b/tests/test_config_flow_compat.py index f2186f0..5bd6769 100644 --- a/tests/test_config_flow_compat.py +++ b/tests/test_config_flow_compat.py @@ -42,7 +42,7 @@ def test_sip_mode_defaults_to_fcm_on_demand(self) -> None: self.assertIn("CONF_SIP_MODE, DEFAULT_SIP_MODE", setup) def test_setup_reuses_access_token_for_existing_phone_entries(self) -> None: - source = Path("custom_components/domru/__init__.py").read_text() + source = Path("custom_components/domru/entry_setup.py").read_text() self.assertIn("entry.data.get(CONF_ACCESS_TOKEN)", source) self.assertIn("or entry.data.get(CONF_REFRESH_TOKEN)", source) diff --git a/tests/test_entry_setup.py b/tests/test_entry_setup.py new file mode 100644 index 0000000..12e3c21 --- /dev/null +++ b/tests/test_entry_setup.py @@ -0,0 +1,212 @@ +# ruff: noqa: D101,D102,D107,EM102,N818,PT009,PT027,S105,TRY003 +"""Tests for config-entry authentication persistence and setup routing.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from typing import Any + + +class ConfigEntryAuthFailed(Exception): + """Home Assistant authentication failure test stub.""" + + +class ConfigEntryNotReady(Exception): + """Home Assistant temporary setup failure test stub.""" + + +class DomruApiClientError(Exception): + """Dom.ru API failure test stub.""" + + +class DomruApiClientCommunicationError(DomruApiClientError): + """Dom.ru communication failure test stub.""" + + +class DomruApiClientAuthenticationError(DomruApiClientError): + """Dom.ru authentication failure test stub.""" + + +class FakeDomruApiClient: + """Capture client construction and expose the auth callback.""" + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.on_auth_update = kwargs["on_auth_update"] + + async def async_authenticate(self) -> None: + return None + + async def async_get_data(self) -> dict[str, Any]: + return {"places": [{"id": "place-1"}]} + + +def _load_entry_setup_module() -> types.ModuleType | None: + module_path = Path("custom_components/domru/entry_setup.py") + if not module_path.exists(): + return None + + package_name = "domru_entry_setup_for_tests" + package = types.ModuleType(package_name) + package.__path__ = [] # type: ignore[attr-defined] + sys.modules[package_name] = package + + exceptions = types.ModuleType("homeassistant.exceptions") + exceptions.ConfigEntryAuthFailed = ConfigEntryAuthFailed + exceptions.ConfigEntryNotReady = ConfigEntryNotReady + sys.modules["homeassistant.exceptions"] = exceptions + + ha_const = types.ModuleType("homeassistant.const") + ha_const.CONF_PASSWORD = "password" + ha_const.CONF_USERNAME = "username" + sys.modules["homeassistant.const"] = ha_const + + api = types.ModuleType(f"{package_name}.api") + api.DomruApiClient = FakeDomruApiClient + api.DomruApiClientAuthenticationError = DomruApiClientAuthenticationError + api.DomruApiClientCommunicationError = DomruApiClientCommunicationError + api.DomruApiClientError = DomruApiClientError + sys.modules[api.__name__] = api + + const = types.ModuleType(f"{package_name}.const") + const.CONF_ACCESS_TOKEN = "access_token" + const.CONF_OPERATOR_ID = "operator_id" + const.CONF_REFRESH_TOKEN = "refresh_token" + sys.modules[const.__name__] = const + + module_name = f"{package_name}.entry_setup" + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot load {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +entry_setup = _load_entry_setup_module() + + +class FakeConfigEntries: + def __init__(self) -> None: + self.updates: list[tuple[object, dict[str, Any]]] = [] + + def async_update_entry(self, entry: object, *, data: dict[str, Any]) -> None: + self.updates.append((entry, data)) + + +class FakeHass: + def __init__(self) -> None: + self.config_entries = FakeConfigEntries() + + +class FakeEntry: + def __init__(self) -> None: + self.data = {"phone": "+79991112233", "refresh_token": "old-refresh"} + + +class FakeClient: + def __init__(self, result: object) -> None: + self.result = result + self.authenticated = False + + async def async_authenticate(self) -> None: + self.authenticated = True + if isinstance(self.result, Exception): + raise self.result + + async def async_get_data(self) -> dict[str, Any]: + if isinstance(self.result, Exception): + raise self.result + return self.result # type: ignore[return-value] + + +class EntrySetupTests(unittest.TestCase): + def setUp(self) -> None: + if entry_setup is None: + self.fail("custom_components/domru/entry_setup.py is missing") + + def test_persist_auth_update_preserves_non_auth_entry_data(self) -> None: + hass = FakeHass() + entry = FakeEntry() + + entry_setup.persist_auth_update( + hass, + entry, + "new-access", + "new-refresh", + 321, + ) + + self.assertEqual( + hass.config_entries.updates, + [ + ( + entry, + { + "phone": "+79991112233", + "access_token": "new-access", + "refresh_token": "new-refresh", + "operator_id": 321, + }, + ) + ], + ) + + def test_initial_auth_failure_requests_reauthentication(self) -> None: + client = FakeClient(DomruApiClientAuthenticationError("expired")) + + with self.assertRaises(ConfigEntryAuthFailed): + asyncio.run(entry_setup.async_load_initial_data(client)) + + def test_initial_api_failure_remains_retryable(self) -> None: + client = FakeClient(DomruApiClientCommunicationError("offline")) + + with self.assertRaises(ConfigEntryNotReady): + asyncio.run(entry_setup.async_load_initial_data(client)) + + def test_initial_data_is_returned_after_authentication(self) -> None: + client = FakeClient({"places": [{"id": "place-1"}]}) + + result = asyncio.run(entry_setup.async_load_initial_data(client)) + + self.assertTrue(client.authenticated) + self.assertEqual(result, {"places": [{"id": "place-1"}]}) + + def test_create_client_wires_rotated_credentials_to_entry_storage(self) -> None: + hass = FakeHass() + entry = FakeEntry() + entry.data.update( + { + "access_token": "stored-access", + "operator_id": 123, + } + ) + session = object() + + self.assertTrue( + hasattr(entry_setup, "async_create_client_and_load_data"), + "entry setup does not construct a persistence-aware API client", + ) + client, initial_data = asyncio.run( + entry_setup.async_create_client_and_load_data(hass, entry, session) + ) + client.on_auth_update("new-access", "new-refresh", 321) + + self.assertEqual(initial_data, {"places": [{"id": "place-1"}]}) + self.assertIs(client.kwargs["session"], session) + self.assertEqual(client.kwargs["access_token"], "stored-access") + self.assertEqual(client.kwargs["refresh_token"], "old-refresh") + self.assertEqual( + hass.config_entries.updates[0][1]["refresh_token"], + "new-refresh", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reauth_flow.py b/tests/test_reauth_flow.py new file mode 100644 index 0000000..c3430aa --- /dev/null +++ b/tests/test_reauth_flow.py @@ -0,0 +1,259 @@ +# ruff: noqa: ANN001,ANN003,ANN201,ANN202,ANN204,D101,D102,D105,D107,EM101,EM102,PT009,S105,SLF001,TRY003 +"""Behavior tests for the Home Assistant reauthentication flow.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class _Schema: + def __init__(self, value): + self.value = value + + +def _field(key, **_kwargs): + return key + + +vol = types.ModuleType("voluptuous") +vol.Schema = _Schema +vol.Required = _field +vol.Optional = _field +vol.UNDEFINED = object() +sys.modules["voluptuous"] = vol + + +class FakeEntry: + def __init__(self) -> None: + self.data = { + "auth_method": "phone", + "phone": "+79991112233", + "account_id": "account-1", + "access_token": "expired-access", + "refresh_token": "expired-refresh", + "operator_id": 123, + "preserved": "value", + } + self.unique_id = "account-1" + + +class ConfigFlow: + def __init_subclass__(cls, **_kwargs): + return super().__init_subclass__() + + def __init__(self) -> None: + self.context = {} + self.hass = object() + self._reauth_entry = FakeEntry() + self._unique_id = None + self.reload_update_calls = 0 + self.listener_update_calls = 0 + + @property + def source(self): + return self.context.get("source") + + async def async_set_unique_id(self, unique_id): + self._unique_id = unique_id + + def _abort_if_unique_id_configured(self): + if self._unique_id == self._reauth_entry.unique_id: + raise RuntimeError("already_configured") + + def _abort_if_unique_id_mismatch(self): + if self._unique_id != self._reauth_entry.unique_id: + raise RuntimeError("wrong_account") + + def _get_reauth_entry(self): + return self._reauth_entry + + def async_update_reload_and_abort(self, entry, *, data_updates): + self.reload_update_calls += 1 + entry.data = {**entry.data, **data_updates} + return {"type": "abort", "reason": "reauth_successful"} + + def async_update_and_abort(self, entry, *, data_updates): + self.listener_update_calls += 1 + entry.data = {**entry.data, **data_updates} + return {"type": "abort", "reason": "reauth_successful"} + + def async_create_entry(self, *, title, data): + return {"type": "create_entry", "title": title, "data": data} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + +class OptionsFlow: + pass + + +config_entries = types.ModuleType("homeassistant.config_entries") +config_entries.ConfigFlow = ConfigFlow +config_entries.OptionsFlow = OptionsFlow +config_entries.ConfigEntry = object +config_entries.ConfigFlowResult = dict +config_entries.FlowResult = dict +config_entries.SOURCE_REAUTH = "reauth" + +homeassistant = types.ModuleType("homeassistant") +homeassistant.config_entries = config_entries +sys.modules["homeassistant"] = homeassistant +sys.modules["homeassistant.config_entries"] = config_entries + +ha_const = types.ModuleType("homeassistant.const") +ha_const.CONF_PASSWORD = "password" +ha_const.CONF_USERNAME = "username" +sys.modules["homeassistant.const"] = ha_const + + +class _Selector: + def __init__(self, _config): + pass + + +class _SelectorConfig: + def __init__(self, **_kwargs): + pass + + +class _SelectorMode: + DROPDOWN = "dropdown" + TEXT = "text" + PASSWORD = "password" + + +selector = types.ModuleType("homeassistant.helpers.selector") +selector.SelectSelector = _Selector +selector.SelectSelectorConfig = _SelectorConfig +selector.SelectSelectorMode = _SelectorMode +selector.TextSelector = _Selector +selector.TextSelectorConfig = _SelectorConfig +selector.TextSelectorType = _SelectorMode +selector.BooleanSelector = _Selector +selector.NumberSelector = _Selector +selector.NumberSelectorConfig = _SelectorConfig + +helpers = types.ModuleType("homeassistant.helpers") +helpers.selector = selector +sys.modules["homeassistant.helpers"] = helpers +sys.modules["homeassistant.helpers.selector"] = selector + +aiohttp_client = types.ModuleType("homeassistant.helpers.aiohttp_client") +aiohttp_client.async_create_clientsession = lambda _hass: object() +sys.modules["homeassistant.helpers.aiohttp_client"] = aiohttp_client + +slugify_module = types.ModuleType("slugify") +slugify_module.slugify = lambda value: str(value) +sys.modules["slugify"] = slugify_module + + +class DomruApiClientError(Exception): + pass + + +class DomruApiClientAuthenticationError(DomruApiClientError): + pass + + +class DomruApiClientCommunicationError(DomruApiClientError): + pass + + +class FakeApiClient: + def __init__(self, **_kwargs) -> None: + self.access_token = None + self.refresh_token = None + self.operator_id = None + + async def async_confirm_phone_code(self, _phone, _code, account) -> None: + self.access_token = "new-access" + self.refresh_token = "new-refresh" + self.operator_id = account["operatorId"] + + +def _load_config_flow(): + package_name = "domru_config_flow_for_tests" + package = types.ModuleType(package_name) + package.__path__ = [] # type: ignore[attr-defined] + sys.modules[package_name] = package + + api = types.ModuleType(f"{package_name}.api") + api.DomruApiClient = FakeApiClient + api.DomruApiClientError = DomruApiClientError + api.DomruApiClientAuthenticationError = DomruApiClientAuthenticationError + api.DomruApiClientCommunicationError = DomruApiClientCommunicationError + sys.modules[api.__name__] = api + + const_path = Path("custom_components/domru/const.py") + const_spec = importlib.util.spec_from_file_location( + f"{package_name}.const", const_path + ) + if const_spec is None or const_spec.loader is None: + raise RuntimeError(f"Cannot load {const_path}") + const = importlib.util.module_from_spec(const_spec) + sys.modules[const_spec.name] = const + const_spec.loader.exec_module(const) + + flow_path = Path("custom_components/domru/config_flow.py") + flow_spec = importlib.util.spec_from_file_location( + f"{package_name}.config_flow", flow_path + ) + if flow_spec is None or flow_spec.loader is None: + raise RuntimeError(f"Cannot load {flow_path}") + flow = importlib.util.module_from_spec(flow_spec) + sys.modules[flow_spec.name] = flow + flow_spec.loader.exec_module(flow) + return flow + + +config_flow = _load_config_flow() + + +class ReauthFlowTests(unittest.TestCase): + def test_reauth_starts_with_confirmation(self) -> None: + flow = config_flow.DomruFlowHandler() + flow.context["source"] = "reauth" + + self.assertTrue( + hasattr(flow, "async_step_reauth"), + "config flow does not implement reauthentication", + ) + result = asyncio.run(flow.async_step_reauth(flow._reauth_entry.data)) + + self.assertEqual(result["type"], "form") + self.assertEqual(result["step_id"], "reauth_confirm") + + def test_phone_sms_reauth_updates_existing_entry(self) -> None: + flow = config_flow.DomruFlowHandler() + flow.context["source"] = "reauth" + flow._phone = "+79991112233" + flow._selected_account = { + "accountId": "account-1", + "operatorId": 321, + "subscriberId": 456, + "address": "Test street", + } + + try: + result = asyncio.run(flow.async_step_sms({"sms_code": "1122"})) + except RuntimeError as exception: + self.fail( + f"Reauthentication tried to create a duplicate entry: {exception}" + ) + + self.assertEqual(result, {"type": "abort", "reason": "reauth_successful"}) + self.assertEqual(flow._reauth_entry.data["access_token"], "new-access") + self.assertEqual(flow._reauth_entry.data["refresh_token"], "new-refresh") + self.assertEqual(flow._reauth_entry.data["preserved"], "value") + self.assertEqual(flow.listener_update_calls, 1) + self.assertEqual(flow.reload_update_calls, 0) + + +if __name__ == "__main__": + unittest.main()