Add cloud onboarding views (#139422)

* Add cloud onboarding views

* Break import cycle when running hassfest

* Add exemption to hassfest for onboarding using cloud

* Adjust according to discussion

* Fix copy-paste errors

* Add tests

* Fix stale docstring

* Import cloud loally
This commit is contained in:
Erik Montnemery 2025-03-24 08:41:19 +01:00 committed by GitHub
parent d3b8dbb76c
commit f4d57e3722
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 270 additions and 7 deletions

View File

@ -245,6 +245,10 @@ class CloudLoginView(HomeAssistantView):
name = "api:cloud:login"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle login request."""
return await self._post(request)
@_handle_cloud_errors
@RequestDataValidator(
vol.Schema(
@ -259,7 +263,7 @@ class CloudLoginView(HomeAssistantView):
)
)
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle login request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@ -316,8 +320,12 @@ class CloudLogoutView(HomeAssistantView):
name = "api:cloud:logout"
@require_admin
@_handle_cloud_errors
async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
return await self._post(request)
@_handle_cloud_errors
async def _post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@ -400,9 +408,13 @@ class CloudForgotPasswordView(HomeAssistantView):
name = "api:cloud:forgot_password"
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle forgot password request."""
return await self._post(request)
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle forgot password request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]

View File

@ -60,6 +60,7 @@ async def async_setup(
hass.http.register_view(BackupInfoView(data))
hass.http.register_view(RestoreBackupView(data))
hass.http.register_view(UploadBackupView(data))
setup_cloud_views(hass, data)
class OnboardingView(HomeAssistantView):
@ -429,6 +430,115 @@ class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView):
return await self._post(request)
def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
"""Set up the cloud views."""
# The cloud integration is imported locally to avoid cloud being imported by
# bootstrap.py and to avoid circular imports.
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.cloud import http_api as cloud_http
# pylint: disable-next=import-outside-toplevel,hass-component-root-import
from homeassistant.components.cloud.const import DATA_CLOUD
class CloudOnboardingView(HomeAssistantView):
"""Cloud onboarding view."""
requires_auth = False
def __init__(self, data: OnboardingStoreData) -> None:
"""Initialize the view."""
self._data = data
def with_cloud[_ViewT: CloudOnboardingView, **_P](
func: Callable[
Concatenate[_ViewT, web.Request, _P],
Coroutine[Any, Any, web.Response],
],
) -> Callable[
Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]
]:
"""Home Assistant API decorator to check onboarding and cloud."""
@wraps(func)
async def _with_cloud(
self: _ViewT,
request: web.Request,
*args: _P.args,
**kwargs: _P.kwargs,
) -> web.Response:
"""Check onboarding status, cloud and call function."""
if self._data["done"]:
# If at least one onboarding step is done, we don't allow accessing
# the cloud onboarding views.
raise HTTPUnauthorized
hass = request.app[KEY_HASS]
if DATA_CLOUD not in hass.data:
return self.json(
{"code": "cloud_disabled"},
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return await func(self, request, *args, **kwargs)
return _with_cloud
class CloudForgotPasswordView(
CloudOnboardingView, cloud_http.CloudForgotPasswordView
):
"""View to start Forgot Password flow."""
url = "/api/onboarding/cloud/forgot_password"
name = "api:onboarding:cloud:forgot_password"
@with_cloud
async def post(self, request: web.Request) -> web.Response:
"""Handle forgot password request."""
return await super()._post(request)
class CloudLoginView(CloudOnboardingView, cloud_http.CloudLoginView):
"""Login to Home Assistant Cloud."""
url = "/api/onboarding/cloud/login"
name = "api:onboarding:cloud:login"
@with_cloud
async def post(self, request: web.Request) -> web.Response:
"""Handle login request."""
return await super()._post(request)
class CloudLogoutView(CloudOnboardingView, cloud_http.CloudLogoutView):
"""Log out of the Home Assistant cloud."""
url = "/api/onboarding/cloud/logout"
name = "api:onboarding:cloud:logout"
@with_cloud
async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
return await super()._post(request)
class CloudStatusView(CloudOnboardingView):
"""Get cloud status view."""
url = "/api/onboarding/cloud/status"
name = "api:onboarding:cloud:status"
@with_cloud
async def get(self, request: web.Request) -> web.Response:
"""Return cloud status."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
return self.json({"logged_in": cloud.is_logged_in})
hass.http.register_view(CloudForgotPasswordView(data))
hass.http.register_view(CloudLoginView(data))
hass.http.register_view(CloudLogoutView(data))
hass.http.register_view(CloudStatusView(data))
@callback
def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider:
"""Get the Home Assistant auth provider."""

View File

@ -173,10 +173,11 @@ IGNORE_VIOLATIONS = {
"logbook",
# Temporary needed for migration until 2024.10
("conversation", "assist_pipeline"),
# The onboarding integration provides a limited backup API used during
# onboarding. The onboarding integration waits for the backup manager
# to be ready before calling any backup functionality.
# The onboarding integration provides limited backup and cloud APIs for use
# during onboarding. The onboarding integration waits for the backup manager
# and cloud to be ready before calling any backup or cloud functionality.
("onboarding", "backup"),
("onboarding", "cloud"),
}

View File

@ -6,12 +6,16 @@ from http import HTTPStatus
from io import StringIO
import os
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, patch
from unittest.mock import ANY, DEFAULT, AsyncMock, MagicMock, Mock, patch
from hass_nabucasa.auth import CognitoAuth
from hass_nabucasa.const import STATE_CONNECTED
from hass_nabucasa.iot import CloudIoT
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components import backup, onboarding
from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN, CloudClient
from homeassistant.components.onboarding import const, views
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@ -1067,3 +1071,139 @@ async def test_onboarding_backup_upload(
assert resp.status == 201
assert await resp.json() == {"backup_id": "abc123"}
mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY)
@pytest.fixture(name="cloud")
async def cloud_fixture() -> AsyncGenerator[MagicMock]:
"""Mock the cloud object.
See the real hass_nabucasa.Cloud class for how to configure the mock.
"""
with patch(
"homeassistant.components.cloud.Cloud", autospec=True
) as mock_cloud_class:
mock_cloud = mock_cloud_class.return_value
mock_cloud.auth = MagicMock(spec=CognitoAuth)
mock_cloud.iot = MagicMock(
spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED
)
def set_up_mock_cloud(
cloud_client: CloudClient, mode: str, **kwargs: Any
) -> DEFAULT:
"""Set up mock cloud with a mock constructor."""
# Attributes set in the constructor with parameters.
mock_cloud.client = cloud_client
return DEFAULT
mock_cloud_class.side_effect = set_up_mock_cloud
# Attributes that we mock with default values.
mock_cloud.id_token = None
mock_cloud.is_logged_in = False
yield mock_cloud
@pytest.fixture(name="setup_cloud")
async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None:
"""Fixture that sets up cloud."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, CLOUD_DOMAIN, {})
await hass.async_block_till_done()
@pytest.mark.usefixtures("setup_cloud")
async def test_onboarding_cloud_forgot_password(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
cloud: MagicMock,
) -> None:
"""Test cloud forgot password."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_block_till_done()
client = await hass_client()
mock_cognito = cloud.auth
req = await client.post(
"/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"}
)
assert req.status == HTTPStatus.OK
assert mock_cognito.async_forgot_password.call_count == 1
@pytest.mark.usefixtures("setup_cloud")
async def test_onboarding_cloud_login(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
cloud: MagicMock,
) -> None:
"""Test logging out from cloud."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_block_till_done()
client = await hass_client()
req = await client.post(
"/api/onboarding/cloud/login",
json={"email": "my_username", "password": "my_password"},
)
assert req.status == HTTPStatus.OK
data = await req.json()
assert data == {"cloud_pipeline": None, "success": True}
assert cloud.login.call_count == 1
@pytest.mark.usefixtures("setup_cloud")
async def test_onboarding_cloud_logout(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
cloud: MagicMock,
) -> None:
"""Test logging out from cloud."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_block_till_done()
client = await hass_client()
req = await client.post("/api/onboarding/cloud/logout")
assert req.status == HTTPStatus.OK
data = await req.json()
assert data == {"message": "ok"}
assert cloud.logout.call_count == 1
@pytest.mark.usefixtures("setup_cloud")
async def test_onboarding_cloud_status(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_client: ClientSessionGenerator,
cloud: MagicMock,
) -> None:
"""Test logging out from cloud."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_block_till_done()
client = await hass_client()
req = await client.get("/api/onboarding/cloud/status")
assert req.status == HTTPStatus.OK
data = await req.json()
assert data == {"logged_in": False}