From 3014abc688bc06b529f87bf9e4963c363d9bef1a Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Fri, 28 Aug 2026 21:43:25 +0300 Subject: [PATCH] Read cloud auto-login state from the hass-nabucasa controller (#180423) --- homeassistant/components/cloud/__init__.py | 7 +- homeassistant/components/cloud/const.py | 6 +- homeassistant/components/cloud/http_api.py | 27 +-- homeassistant/components/cloud/models.py | 18 +- tests/components/cloud/conftest.py | 38 +++- tests/components/cloud/test_http_api.py | 231 +++++++++++++++++++-- tests/components/cloud/test_init.py | 105 +++++++++- tests/components/cloud/test_models.py | 31 +++ 8 files changed, 386 insertions(+), 77 deletions(-) create mode 100644 tests/components/cloud/test_models.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 6cd98ea30c53..de236c40b9e8 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -386,17 +386,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_create_cloud_pipeline(hass) async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"}) - async def _on_cloud_login_failed(event: CloudEvent) -> None: + def _on_cloud_login_failed(event: CloudEvent) -> None: """Handle hass_nabucasa giving up on a pending auto-login.""" # The event bus types every handler against the CloudEvent base class. if not isinstance(event, LoginFailedEvent) or not event.auto: return - # Keep the registration around, so a client that was not connected when - # this fired can still find out why it stopped. - if pending := hass.data[DATA_PENDING_AUTO_LOGIN]: - pending.mark_failed(event.reason) - async_dispatcher_send( hass, EVENT_CLOUD_EVENT, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 659a5236f647..31bd796eea3b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -7,11 +7,10 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.signal_type import SignalType if TYPE_CHECKING: - from hass_nabucasa import Cloud + from hass_nabucasa import AutoLoginController, Cloud from .client import CloudClient from .helpers import FixedSizeQueueLogHandler - from .models import PendingAutoLogin DOMAIN = "cloud" DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN) @@ -19,7 +18,8 @@ DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey( "cloud_platforms_setup" ) DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler") -DATA_PENDING_AUTO_LOGIN: HassKey[PendingAutoLogin | None] = HassKey( +# In memory only, so a restart drops the registration and the user signs in by hand. +DATA_PENDING_AUTO_LOGIN: HassKey[AutoLoginController | None] = HassKey( "cloud_pending_auto_login" ) EVENT_CLOUD_EVENT = "cloud_event" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 191d85093ec8..d95a49c36385 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -71,7 +71,7 @@ from .const import ( VOICE_STYLE_SEPERATOR, ) from .google_config import CLOUD_GOOGLE -from .models import PendingAutoLogin, auto_login_failure_key +from .models import auto_login_failure_key from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -425,23 +425,15 @@ class CloudRegisterAutoLoginView(HomeAssistantView): hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] - if cloud.is_logged_in: - raise auth.AlreadyLoggedIn("Cannot register if already logged in.") - client_metadata = await _async_location_client_metadata(hass) async with asyncio.timeout(REQUEST_TIMEOUT): - controller = await cloud.register_and_auto_login( + hass.data[DATA_PENDING_AUTO_LOGIN] = await cloud.register_and_auto_login( data["email"], data["password"], client_metadata=client_metadata, ) - hass.data[DATA_PENDING_AUTO_LOGIN] = PendingAutoLogin( - # hass_nabucasa registers and logs in with the lowercased address. - email=data["email"].lower(), - controller=controller, - ) return self.json_message("ok") @@ -728,19 +720,18 @@ class DownloadSupportPackageView(HomeAssistantView): @callback def _async_auto_login_controller(hass: HomeAssistant) -> AutoLoginController | None: """Return the controls of a retry loop that is still running.""" - pending = hass.data[DATA_PENDING_AUTO_LOGIN] - return pending.controller if pending is not None else None + controller = hass.data[DATA_PENDING_AUTO_LOGIN] + return controller if controller is not None and controller.active else None @callback def _async_clear_pending_auto_login(hass: HomeAssistant) -> None: """Cancel and forget a pending auto-login, telling subscribers it is gone.""" - if (pending := hass.data[DATA_PENDING_AUTO_LOGIN]) is None: + if (controller := hass.data[DATA_PENDING_AUTO_LOGIN]) is None: return hass.data[DATA_PENDING_AUTO_LOGIN] = None - if pending.controller is not None: - pending.controller.cancel() + controller.cancel() async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "auto_login_cancelled"}) @@ -792,11 +783,11 @@ async def websocket_cloud_status( if ( not cloud.is_logged_in and connection.user.is_admin - and (pending := hass.data[DATA_PENDING_AUTO_LOGIN]) + and (controller := hass.data[DATA_PENDING_AUTO_LOGIN]) ): data["auto_login"] = { - "email": pending.email, - "failed": auto_login_failure_key(pending.failed_reason), + "email": controller.email, + "failed": auto_login_failure_key(controller.failed_reason), } connection.send_message(websocket_api.result_message(msg["id"], data)) diff --git a/homeassistant/components/cloud/models.py b/homeassistant/components/cloud/models.py index 2839b8a18ba0..dcc8282ebb42 100644 --- a/homeassistant/components/cloud/models.py +++ b/homeassistant/components/cloud/models.py @@ -1,8 +1,6 @@ """Models for the cloud integration.""" -import dataclasses - -from hass_nabucasa import AutoLoginController, LoginFailedReason +from hass_nabucasa import LoginFailedReason # Spelled out rather than derived from the reason, so the keys stay greppable and # a reason hass_nabucasa adds later does not silently point at a missing string. @@ -20,17 +18,3 @@ def auto_login_failure_key(reason: LoginFailedReason | None) -> str | None: return AUTO_LOGIN_FAILED_TRANSLATION_KEYS.get( reason, "auto_login_failed_unexpected_error" ) - - -@dataclasses.dataclass -class PendingAutoLogin: - """A registration waiting for its email confirmation to log in.""" - - email: str - controller: AutoLoginController | None - failed_reason: LoginFailedReason | None = None - - def mark_failed(self, reason: LoginFailedReason) -> None: - """Record that the retry loop gave up before logging in.""" - self.controller = None - self.failed_reason = reason diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index ccaceb511cc1..9daf42f332c3 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -6,14 +6,14 @@ from typing import Any from unittest.mock import DEFAULT, AsyncMock, MagicMock, PropertyMock, patch from hass_nabucasa import ( + AutoLoginController, Cloud, - CloudEvent, CloudEventBus, - CloudEventType, LoginEvent, + LogoutEvent, payments_api, ) -from hass_nabucasa.auth import CognitoAuth +from hass_nabucasa.auth import AlreadyLoggedIn, CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED from hass_nabucasa.files import Files @@ -102,12 +102,30 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: ), ) mock_cloud.llm = MagicMock(async_ensure_token=AsyncMock()) - mock_cloud.register_and_auto_login.return_value = MagicMock( - spec_set=["cancel", "attempt_now", "resend"], - cancel=MagicMock(), - attempt_now=MagicMock(), - resend=AsyncMock(), - ) + + def mock_register_and_auto_login( + email: str, + password: str, + *, + client_metadata: dict[str, str] | None = None, + ) -> AutoLoginController: + """Mock registering, handing back a freshly started controller. + + A distinct controller per call, like the real Cloud, so a test can tell a + replaced registration from one that was left in place. + """ + if mock_cloud.is_logged_in: + raise AlreadyLoggedIn( + "Cannot register and auto-login while already logged in." + ) + return AutoLoginController( + email=email.lower(), + cancel=MagicMock(), + attempt_now=MagicMock(), + resend=AsyncMock(), + ) + + mock_cloud.register_and_auto_login.side_effect = mock_register_and_auto_login def set_up_mock_cloud( cloud_client: CloudClient, mode: str, **kwargs: Any @@ -218,7 +236,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: async def mock_logout() -> None: """Mock logout.""" # The real Cloud publishes LOGOUT before clearing state. - await mock_cloud.events.publish(CloudEvent(type=CloudEventType.LOGOUT)) + await mock_cloud.events.publish(LogoutEvent()) mock_cloud.id_token = None mock_cloud.access_token = None mock_cloud.refresh_token = None diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index be8372ddccf4..3d57ac36f5e1 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,6 +14,7 @@ from freezegun.api import FrozenDateTimeFactory from hass_nabucasa import ( AlreadyConnectedError, AuthTimeoutError, + AutoLoginController, LoginFailedEvent, LoginFailedReason, ) @@ -38,7 +39,11 @@ from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.assist_pipeline.pipeline import ( # pylint: disable=home-assistant-component-root-import STORAGE_KEY, ) -from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN +from homeassistant.components.cloud.const import ( + DATA_PENDING_AUTO_LOGIN, + DEFAULT_EXPOSED_DOMAINS, + DOMAIN, +) from homeassistant.components.cloud.http_api import validate_language_voice from homeassistant.components.frontend import DATA_THEMES from homeassistant.components.google_assistant.helpers import ( # pylint: disable=home-assistant-component-root-import @@ -824,6 +829,23 @@ async def register_auto_login( assert req.status == HTTPStatus.OK +def pending_controller(hass: HomeAssistant) -> AutoLoginController: + """Return the controller core is holding for the pending registration.""" + controller = hass.data[DATA_PENDING_AUTO_LOGIN] + assert controller is not None + return controller + + +async def fail_auto_login( + hass: HomeAssistant, cloud: MagicMock, reason: LoginFailedReason +) -> None: + """Give up on the pending auto-login the way hass_nabucasa does.""" + controller = pending_controller(hass) + controller.failed_reason = reason + controller.active = False + await cloud.events.publish(LoginFailedEvent(auto=True, reason=reason)) + + async def get_cloud_status( client: MockHAClientWebSocket, msg_id: int ) -> dict[str, Any]: @@ -867,6 +889,93 @@ async def test_register_auto_login_reports_normalized_email( assert status["auto_login"]["email"] == "hello@bla.com" +@pytest.mark.usefixtures("setup_cloud") +async def test_register_auto_login_already_given_up( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a retry loop that gave up before the view stored its controller. + + The controller is the single source of truth, so the reason survives even though + the LOGIN_FAILED event fired before there was anything to store. + """ + cloud.id_token = None + + def give_up_immediately(*args: Any, **kwargs: Any) -> AutoLoginController: + """Return a controller whose retry loop has already given up.""" + return AutoLoginController( + email="hello@bla.com", + cancel=MagicMock(), + attempt_now=MagicMock(), + resend=AsyncMock(), + active=False, + failed_reason=LoginFailedReason.TIMEOUT, + ) + + cloud.register_and_auto_login.side_effect = give_up_immediately + await register_auto_login(hass_client) + + client = await hass_ws_client(hass) + status = await get_cloud_status(client, 5) + assert status["auto_login"] == { + "email": "hello@bla.com", + "failed": "auto_login_failed_timeout", + } + + await client.send_json({"id": 6, "type": "cloud/attempt_auto_login_now"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["translation_key"] == "no_pending_auto_login" + + +@pytest.mark.usefixtures("setup_cloud") +async def test_register_auto_login_replaces_pending( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a second registration takes over from the first.""" + cloud.id_token = None + await register_auto_login(hass_client) + first = pending_controller(hass) + + await register_auto_login(hass_client, email="second@bla.com") + second = pending_controller(hass) + + assert second is not first + assert first.email == "hello@bla.com" + assert second.email == "second@bla.com" + # hass_nabucasa cancels the superseded retry loop itself. + assert first.cancel.call_count == 0 + + client = await hass_ws_client(hass) + status = await get_cloud_status(client, 5) + assert status["auto_login"] == {"email": "second@bla.com", "failed": None} + + +@pytest.mark.usefixtures("setup_cloud") +async def test_auto_login_hidden_while_logged_in( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a leftover pending registration is not reported once signed in.""" + id_token = cloud.id_token + cloud.id_token = None + await register_auto_login(hass_client) + cloud.id_token = id_token + + client = await hass_ws_client(hass) + status = await get_cloud_status(client, 5) + + assert status["logged_in"] is True + assert status["auto_login"] is None + + @pytest.mark.usefixtures("setup_cloud") async def test_register_auto_login_while_logged_in( cloud: MagicMock, @@ -874,13 +983,17 @@ async def test_register_auto_login_while_logged_in( ) -> None: """Test registering with auto-login is refused while already logged in.""" cloud_client = await hass_client() - req = await cloud_client.post( - "/api/cloud/register_auto_login", - json={"email": "hello@bla.com", "password": "falcon42"}, - ) + with patch( + "homeassistant.components.cloud.http_api.async_detect_location_info", + return_value=None, + ): + req = await cloud_client.post( + "/api/cloud/register_auto_login", + json={"email": "hello@bla.com", "password": "falcon42"}, + ) assert req.status == HTTPStatus.BAD_REQUEST - cloud.register_and_auto_login.assert_not_called() + cloud.auth.async_register.assert_not_called() @pytest.mark.usefixtures("setup_cloud") @@ -911,13 +1024,14 @@ async def test_cancel_auto_login( """Test cancelling a pending auto-login.""" cloud.id_token = None await register_auto_login(hass_client) + controller = pending_controller(hass) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/cancel_auto_login"}) response = await client.receive_json() assert response["success"] - assert cloud.register_and_auto_login.return_value.cancel.call_count == 1 + assert controller.cancel.call_count == 1 status = await get_cloud_status(client, 6) assert status["auto_login"] is None @@ -980,8 +1094,8 @@ async def test_auto_login_command_without_pending( # The frontend renders the message from the translation key, not the text. assert response["error"]["translation_domain"] == DOMAIN assert response["error"]["translation_key"] == "no_pending_auto_login" - controller = cloud.register_and_auto_login.return_value - assert getattr(controller, controller_method).call_count == 0 + assert hass.data[DATA_PENDING_AUTO_LOGIN] is None + cloud.register_and_auto_login.assert_not_called() @pytest.mark.parametrize( @@ -1020,7 +1134,7 @@ async def test_auto_login_failure_pushed( response = await client.receive_json() assert response["success"] - await cloud.events.publish(LoginFailedEvent(auto=True, reason=reason)) + await fail_auto_login(hass, cloud, reason) event = await client.receive_json() assert event["id"] == 5 @@ -1055,9 +1169,7 @@ async def test_auto_login_command_after_failure( """Test the retry commands are refused once the retry loop gave up.""" cloud.id_token = None await register_auto_login(hass_client) - await cloud.events.publish( - LoginFailedEvent(auto=True, reason=LoginFailedReason.TIMEOUT) - ) + await fail_auto_login(hass, cloud, LoginFailedReason.TIMEOUT) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": command}) @@ -1078,9 +1190,7 @@ async def test_cancel_auto_login_after_failure( """Test the failed registration can be dismissed.""" cloud.id_token = None await register_auto_login(hass_client) - await cloud.events.publish( - LoginFailedEvent(auto=True, reason=LoginFailedReason.TIMEOUT) - ) + await fail_auto_login(hass, cloud, LoginFailedReason.TIMEOUT) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/cancel_auto_login"}) @@ -1194,13 +1304,14 @@ async def test_logout_clears_auto_login( """Test logging out clears a pending auto-login.""" cloud.id_token = None await register_auto_login(hass_client) + controller = pending_controller(hass) cloud_client = await hass_client() req = await cloud_client.post("/api/cloud/logout") assert req.status == HTTPStatus.OK # The library cancels the auto-login task on logout itself. - assert cloud.register_and_auto_login.return_value.cancel.call_count == 0 + assert controller.cancel.call_count == 0 client = await hass_ws_client(hass) status = await get_cloud_status(client, 5) @@ -1226,7 +1337,7 @@ async def test_auto_login_command_keeps_pending( """Test forcing an attempt or resending the mail leaves the retry loop alone.""" cloud.id_token = None await register_auto_login(hass_client) - controller = cloud.register_and_auto_login.return_value + controller = pending_controller(hass) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": command}) @@ -1249,9 +1360,8 @@ async def test_resend_auto_login_confirm_error( ) -> None: """Test a failing resend of the confirmation email.""" cloud.id_token = None - controller = cloud.register_and_auto_login.return_value - controller.resend.side_effect = UnknownError await register_auto_login(hass_client) + pending_controller(hass).resend.side_effect = UnknownError client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/resend_auto_login_confirm"}) @@ -1263,6 +1373,57 @@ async def test_resend_auto_login_confirm_error( assert status["auto_login"]["email"] == "hello@bla.com" +@pytest.mark.usefixtures("setup_cloud") +async def test_auto_login_failed_pushed_without_pending( + hass: HomeAssistant, + cloud: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the give-up push does not depend on core still tracking a registration.""" + cloud.id_token = None + + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/subscribe_events"}) + assert (await client.receive_json())["success"] + + await cloud.events.publish( + LoginFailedEvent(auto=True, reason=LoginFailedReason.TIMEOUT) + ) + + event = await client.receive_json() + assert event["id"] == 5 + assert event["event"] == { + "type": "auto_login_failed", + "translation_key": "auto_login_failed_timeout", + } + + status = await get_cloud_status(client, 6) + assert status["auto_login"] is None + + +@pytest.mark.usefixtures("setup_cloud") +async def test_resend_auto_login_confirm_timeout( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a resend that never comes back is reported as a gateway timeout.""" + cloud.id_token = None + await register_auto_login(hass_client) + pending_controller(hass).resend.side_effect = TimeoutError + + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/resend_auto_login_confirm"}) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == str(HTTPStatus.BAD_GATEWAY) + + status = await get_cloud_status(client, 6) + assert status["auto_login"]["email"] == "hello@bla.com" + + @pytest.mark.usefixtures("setup_cloud") async def test_remove_data_cancels_auto_login( hass: HomeAssistant, @@ -1273,18 +1434,43 @@ async def test_remove_data_cancels_auto_login( """Test removing the cloud data cancels a pending auto-login.""" cloud.id_token = None await register_auto_login(hass_client) + controller = pending_controller(hass) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/remove_data"}) response = await client.receive_json() assert response["success"] - assert cloud.register_and_auto_login.return_value.cancel.call_count == 1 + assert controller.cancel.call_count == 1 status = await get_cloud_status(client, 6) assert status["auto_login"] is None +@pytest.mark.usefixtures("setup_cloud") +async def test_remove_data_logged_in_keeps_auto_login( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the logged-in refusal returns before anything is cancelled.""" + id_token = cloud.id_token + cloud.id_token = None + await register_auto_login(hass_client) + controller = pending_controller(hass) + cloud.id_token = id_token + + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/remove_data"}) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "logged_in" + cloud.remove_data.assert_not_called() + assert controller.cancel.call_count == 0 + + @pytest.mark.usefixtures("setup_cloud") async def test_remove_data_failure_still_cancels_auto_login( hass: HomeAssistant, @@ -1296,6 +1482,7 @@ async def test_remove_data_failure_still_cancels_auto_login( cloud.id_token = None cloud.remove_data.side_effect = ValueError("Cloud not stopped") await register_auto_login(hass_client) + controller = pending_controller(hass) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/remove_data"}) @@ -1303,7 +1490,7 @@ async def test_remove_data_failure_still_cancels_auto_login( assert not response["success"] # Cancelled before the wipe starts, so no login can land on erased data. - assert cloud.register_and_auto_login.return_value.cancel.call_count == 1 + assert controller.cancel.call_count == 1 status = await get_cloud_status(client, 6) assert status["auto_login"] is None diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 176c66c1ebfd..9df1c7a18243 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -4,6 +4,13 @@ from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import MagicMock, patch +from hass_nabucasa import ( + AutoLoginController, + LoginEvent, + LoginFailedEvent, + LoginFailedReason, + LogoutEvent, +) import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -18,15 +25,18 @@ from homeassistant.components.cloud import ( ) from homeassistant.components.cloud.const import ( DATA_CLOUD, + DATA_PENDING_AUTO_LOGIN, DOMAIN, + EVENT_CLOUD_EVENT, MODE_DEV, PREF_CLOUDHOOKS, ) from homeassistant.components.cloud.prefs import STORAGE_KEY from homeassistant.const import CONF_MODE, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockUser @@ -376,6 +386,99 @@ async def test_logout_removes_config_entry( ) +@pytest.fixture(name="pending_auto_login") +async def pending_auto_login_fixture( + hass: HomeAssistant, cloud: MagicMock +) -> AutoLoginController: + """Set up cloud with a registration waiting for its confirmation.""" + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + controller = cloud.register_and_auto_login.return_value + controller.email = "hello@bla.com" + hass.data[DATA_PENDING_AUTO_LOGIN] = controller + return controller + + +def _track_cloud_events(hass: HomeAssistant) -> list[dict[str, Any]]: + """Collect the payloads pushed on the cloud event signal.""" + events: list[dict[str, Any]] = [] + + @callback + def collect(event: dict[str, Any]) -> None: + events.append(event) + + async_dispatcher_connect(hass, EVENT_CLOUD_EVENT, collect) + return events + + +@pytest.mark.usefixtures("pending_auto_login") +async def test_login_event_clears_pending_auto_login( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test a successful login forgets the registration and tells the frontend.""" + events = _track_cloud_events(hass) + + await cloud.events.publish(LoginEvent(auto=True)) + + assert hass.data[DATA_PENDING_AUTO_LOGIN] is None + assert events == [{"type": "login"}] + assert cloud.register_and_auto_login.return_value.cancel.call_count == 0 + + +@pytest.mark.usefixtures("pending_auto_login") +async def test_logout_event_clears_pending_auto_login( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test a logout forgets the registration without pushing an event.""" + events = _track_cloud_events(hass) + + await cloud.events.publish(LogoutEvent()) + + assert hass.data[DATA_PENDING_AUTO_LOGIN] is None + assert events == [] + + +async def test_auto_login_failed_event_keeps_controller( + hass: HomeAssistant, + cloud: MagicMock, + pending_auto_login: AutoLoginController, +) -> None: + """Test giving up pushes the reason and leaves the controller in place.""" + events = _track_cloud_events(hass) + + pending_auto_login.failed_reason = LoginFailedReason.CLOUD_ERROR + pending_auto_login.active = False + await cloud.events.publish( + LoginFailedEvent(auto=True, reason=LoginFailedReason.CLOUD_ERROR) + ) + + assert hass.data[DATA_PENDING_AUTO_LOGIN] is pending_auto_login + assert events == [ + { + "type": "auto_login_failed", + "translation_key": "auto_login_failed_cloud_error", + } + ] + + +@pytest.mark.usefixtures("pending_auto_login") +async def test_interactive_login_failed_event_ignored( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test a failed interactive login is left to the caller that asked for it.""" + events = _track_cloud_events(hass) + + await cloud.events.publish( + LoginFailedEvent(reason=LoginFailedReason.UNEXPECTED_ERROR) + ) + + assert events == [] + + async def test_async_listen_cloudhook_change( hass: HomeAssistant, cloud: MagicMock, diff --git a/tests/components/cloud/test_models.py b/tests/components/cloud/test_models.py new file mode 100644 index 000000000000..5f577aa9fae1 --- /dev/null +++ b/tests/components/cloud/test_models.py @@ -0,0 +1,31 @@ +"""Tests for the cloud integration models.""" + +from typing import cast + +from hass_nabucasa import LoginFailedReason +import pytest + +from homeassistant.components.cloud.models import auto_login_failure_key + + +@pytest.mark.parametrize( + ("reason", "translation_key"), + [ + (None, None), + (LoginFailedReason.TIMEOUT, "auto_login_failed_timeout"), + (LoginFailedReason.CLOUD_ERROR, "auto_login_failed_cloud_error"), + ( + LoginFailedReason.UNEXPECTED_ERROR, + "auto_login_failed_unexpected_error", + ), + ( + cast(LoginFailedReason, "brand_new_reason"), + "auto_login_failed_unexpected_error", + ), + ], +) +def test_auto_login_failure_key( + reason: LoginFailedReason | None, translation_key: str | None +) -> None: + """Test every reason maps to a string that exists.""" + assert auto_login_failure_key(reason) == translation_key