Add repair for legacy subscription to cloud integration (#82621)

This commit is contained in:
Joakim Sørensen 2022-11-28 23:35:24 +01:00 committed by GitHub
parent 82b2aaaa3e
commit 7c82b78f8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 442 additions and 14 deletions

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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."
}
}
}
}
}

View file

@ -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

View file

@ -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",

View file

@ -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."""

View file

@ -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."""

View file

@ -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"
)