mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 12:02:16 +01:00
Read cloud auto-login state from the hass-nabucasa controller (#180423)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user