Read cloud auto-login state from the hass-nabucasa controller (#180423)

This commit is contained in:
Krisjanis Lejejs
2026-08-28 20:05:00 +00:00
committed by Franck Nijhof
parent 1d9b371543
commit 3014abc688
8 changed files with 386 additions and 77 deletions
+1 -6
View File
@@ -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,
+3 -3
View File
@@ -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"
+9 -18
View File
@@ -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))
+1 -17
View File
@@ -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
+28 -10
View File
@@ -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
+209 -22
View File
@@ -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
+104 -1
View File
@@ -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,
+31
View File
@@ -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