diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 6a948c0ad15..0662487b765 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from datetime import timedelta from enum import Enum from hass_nabucasa import Cloud @@ -26,6 +27,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -55,6 +57,8 @@ from .const import ( MODE_PROD, ) from .prefs import CloudPreferences +from .repairs import async_manage_legacy_subscription_issue +from .subscription import async_subscription_info DEFAULT_MODE = MODE_PROD @@ -258,6 +262,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: loaded = False + async def async_startup_repairs(_=None) -> None: + """Create repair issues after startup.""" + if not cloud.is_logged_in: + return + + if subscription_info := await async_subscription_info(cloud): + async_manage_legacy_subscription_issue(hass, subscription_info) + async def _on_connect(): """Discover RemoteUI binary sensor.""" nonlocal loaded @@ -294,6 +306,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: account_link.async_setup(hass) + async_call_later( + hass=hass, + delay=timedelta(hours=1), + action=async_startup_repairs, + ) + return True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 01b6cd17508..76d8bea1664 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -9,7 +9,7 @@ from typing import Any import aiohttp import async_timeout import attr -from hass_nabucasa import Cloud, auth, cloud_api, thingtalk +from hass_nabucasa import Cloud, auth, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol @@ -38,6 +38,8 @@ from .const import ( PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .repairs import async_manage_legacy_subscription_issue +from .subscription import async_subscription_info _LOGGER = logging.getLogger(__name__) @@ -328,15 +330,14 @@ async def websocket_subscription( ) -> None: """Handle request for account info.""" cloud = hass.data[DOMAIN] - try: - async with async_timeout.timeout(REQUEST_TIMEOUT): - data = await cloud_api.async_subscription_info(cloud) - except aiohttp.ClientError: + if (data := await async_subscription_info(cloud)) is None: connection.send_error( msg["id"], "request_failed", "Failed to request subscription" ) - else: - connection.send_result(msg["id"], data) + return + + connection.send_result(msg["id"], data) + async_manage_legacy_subscription_issue(hass, data) @_require_cloud_login diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py new file mode 100644 index 00000000000..779c0eb64b0 --- /dev/null +++ b/homeassistant/components/cloud/repairs.py @@ -0,0 +1,121 @@ +"""Repairs implementation for the cloud integration.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from hass_nabucasa import Cloud +import voluptuous as vol + +from homeassistant.components.repairs import RepairsFlow, repairs_flow_manager +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN +from .subscription import async_subscription_info + +BACKOFF_TIME = 5 +MAX_RETRIES = 60 # This allows for 10 minutes of retries + + +@callback +def async_manage_legacy_subscription_issue( + hass: HomeAssistant, + subscription_info: dict[str, Any], +) -> None: + """ + Manage the legacy subscription issue. + + If the provider is "legacy" create an issue, + in all other cases remove the issue. + """ + if subscription_info["provider"] == "legacy": + ir.async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id="legacy_subscription", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_subscription", + ) + return + ir.async_delete_issue(hass=hass, domain=DOMAIN, issue_id="legacy_subscription") + + +class LegacySubscriptionRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + wait_task: asyncio.Task | None = None + _data: dict[str, Any] | None = None + + async def async_step_init(self, _: None = None) -> FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm_change_plan() + + async def async_step_confirm_change_plan( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + return await self.async_step_change_plan() + + return self.async_show_form( + step_id="confirm_change_plan", data_schema=vol.Schema({}) + ) + + async def async_step_change_plan(self, _: None = None) -> FlowResult: + """Wait for the user to authorize the app installation.""" + + async def _async_wait_for_plan_change() -> None: + flow_manager = repairs_flow_manager(self.hass) + # We can not get here without a flow manager + assert flow_manager is not None + + cloud: Cloud = self.hass.data[DOMAIN] + + retries = 0 + while retries < MAX_RETRIES: + self._data = await async_subscription_info(cloud) + if self._data is not None and self._data["provider"] != "legacy": + break + + retries += 1 + await asyncio.sleep(BACKOFF_TIME) + + self.hass.async_create_task( + flow_manager.async_configure(flow_id=self.flow_id) + ) + + if not self.wait_task: + self.wait_task = self.hass.async_create_task(_async_wait_for_plan_change()) + return self.async_external_step( + step_id="change_plan", + url="https://account.nabucasa.com/", + ) + + await self.wait_task + + if self._data is None or self._data["provider"] == "legacy": + # If we get here we waited too long. + return self.async_external_step_done(next_step_id="timeout") + + return self.async_external_step_done(next_step_id="complete") + + async def async_step_complete(self, _: None = None) -> FlowResult: + """Handle the final step of a fix flow.""" + return self.async_create_entry(title="", data={}) + + async def async_step_timeout(self, _: None = None) -> FlowResult: + """Handle the final step of a fix flow.""" + return self.async_abort(reason="operation_took_too_long") + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + return LegacySubscriptionRepairFlow() diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index a799a8cee59..e437fca9ed3 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -13,5 +13,20 @@ "logged_in": "Logged In", "subscription_expiration": "Subscription Expiration" } + }, + "issues": { + "legacy_subscription": { + "title": "Legacy subscription detected", + "fix_flow": { + "step": { + "confirm_change_plan": { + "description": "We've recently updated our subscription system. To continue using Home Assistant Cloud you need to one-time approve the change in PayPal.\n\nThis takes 1 minute and will not increase the price." + } + }, + "abort": { + "operation_took_too_long": "The operation took too long. Please try again later." + } + } + } } } diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py new file mode 100644 index 00000000000..9a2e5bd87cf --- /dev/null +++ b/homeassistant/components/cloud/subscription.py @@ -0,0 +1,24 @@ +"""Subscription information.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp.client_exceptions import ClientError +import async_timeout +from hass_nabucasa import Cloud, cloud_api + +from .const import REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: + """Fetch the subscription info.""" + try: + async with async_timeout.timeout(REQUEST_TIMEOUT): + return await cloud_api.async_subscription_info(cloud) + except ClientError: + _LOGGER.error("Failed to fetch subscription information") + + return None diff --git a/homeassistant/components/cloud/translations/en.json b/homeassistant/components/cloud/translations/en.json index 7577a9a51e4..fa7376f80c8 100644 --- a/homeassistant/components/cloud/translations/en.json +++ b/homeassistant/components/cloud/translations/en.json @@ -1,4 +1,19 @@ { + "issues": { + "legacy_subscription": { + "fix_flow": { + "abort": { + "operation_took_too_long": "The operation took too long. Please try again later." + }, + "step": { + "confirm_change_plan": { + "description": "We've recently updated our subscription system. To continue using Home Assistant Cloud you need to one-time approve the change in PayPal.\n\nThis takes 1 minute and will not increase the price." + } + } + }, + "title": "Legacy subscription detected" + } + }, "system_health": { "info": { "alexa_enabled": "Alexa Enabled", diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 4bd5868db5f..e16fb63b34a 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -52,6 +52,15 @@ def mock_cloud_login(hass, mock_cloud_setup): yield +@pytest.fixture(name="mock_auth") +def mock_auth_fixture(): + """Mock check token.""" + with patch("hass_nabucasa.auth.CognitoAuth.async_check_token"), patch( + "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" + ): + yield + + @pytest.fixture def mock_expired_cloud_login(hass, mock_cloud_setup): """Mock cloud is logged in.""" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 4d0729d72b2..1f9af960f08 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -24,13 +24,6 @@ from tests.components.google_assistant import MockConfig SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/subscription_info" -@pytest.fixture(name="mock_auth") -def mock_auth_fixture(): - """Mock check token.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_check_token"): - yield - - @pytest.fixture(name="mock_cloud_login") def mock_cloud_login_fixture(hass, setup_api): """Mock cloud is logged in.""" diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py new file mode 100644 index 00000000000..ef96efaa402 --- /dev/null +++ b/tests/components/cloud/test_repairs.py @@ -0,0 +1,232 @@ +"""Test cloud repairs.""" +from collections.abc import Awaitable, Callable, Generator +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +from aiohttp import ClientSession + +from homeassistant.components.cloud import DOMAIN +import homeassistant.components.cloud.repairs as cloud_repairs +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from . import mock_cloud + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( + hass: HomeAssistant, +): + """Test that we create repair issue at startup if we are logged in.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + with patch("homeassistant.components.cloud.Cloud.is_logged_in", False): + await mock_cloud(hass) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1)) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + + +async def test_create_repair_issues_at_startup_if_logged_in( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_auth: Generator[None, AsyncMock, None], +): + """Test that we create repair issue at startup if we are logged in.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + aioclient_mock.get( + "https://accounts.nabucasa.com/payments/subscription_info", + json={"provider": "legacy"}, + ) + + with patch("homeassistant.components.cloud.Cloud.is_logged_in", True): + await mock_cloud(hass) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1)) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + + +async def test_legacy_subscription_delete_issue_if_no_longer_legacy( + hass: HomeAssistant, +): + """Test that we delete the legacy subscription issue if no longer legacy.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) + assert issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + + cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": None}) + assert not issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + + +async def test_legacy_subscription_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_auth: Generator[None, AsyncMock, None], + hass_client: Callable[..., Awaitable[ClientSession]], +): + """Test desired flow of the fix flow for legacy subscription.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + aioclient_mock.get( + "https://accounts.nabucasa.com/payments/subscription_info", + json={"provider": None}, + ) + + cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) + repair_issue = issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + assert repair_issue + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await mock_cloud(hass) + await hass.async_block_till_done() + await hass.async_start() + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": DOMAIN, + "step_id": "confirm_change_plan", + "data_schema": [], + "errors": None, + "description_placeholders": None, + "last_step": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "external", + "flow_id": flow_id, + "handler": DOMAIN, + "step_id": "change_plan", + "url": "https://account.nabucasa.com/", + "description_placeholders": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": DOMAIN, + "title": "", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + + +async def test_legacy_subscription_repair_flow_timeout( + hass: HomeAssistant, + hass_client: Callable[..., Awaitable[ClientSession]], +): + """Test timeout flow of the fix flow for legacy subscription.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) + repair_issue = issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + ) + assert repair_issue + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await mock_cloud(hass) + await hass.async_block_till_done() + await hass.async_start() + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": DOMAIN, + "step_id": "confirm_change_plan", + "data_schema": [], + "errors": None, + "description_placeholders": None, + "last_step": None, + } + + with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0): + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "external", + "flow_id": flow_id, + "handler": DOMAIN, + "step_id": "change_plan", + "url": "https://account.nabucasa.com/", + "description_placeholders": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "abort", + "flow_id": flow_id, + "handler": "cloud", + "reason": "operation_took_too_long", + "description_placeholders": None, + "result": None, + } + + assert issue_registry.async_get_issue( + domain="cloud", issue_id="legacy_subscription" + )