mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 17:27:52 +00:00
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:
parent
d3b8dbb76c
commit
f4d57e3722
@ -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]
|
||||
|
@ -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."""
|
||||
|
@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
@ -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}
|
||||
|
Loading…
x
Reference in New Issue
Block a user