Guide users to migrate from Ubiquiti Cloud Accounts to local for UniFi Protect (#111018)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Christopher Bailey 2024-02-21 00:32:47 -05:00 committed by GitHub
parent fb04df5392
commit 7eb6614818
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 260 additions and 40 deletions

View file

@ -61,7 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
try:
nvr_info = await protect.get_nvr()
bootstrap = await protect.get_bootstrap()
nvr_info = bootstrap.nvr
except NotAuthorized as err:
retry_key = f"{entry.entry_id}_auth"
retries = hass.data.setdefault(DOMAIN, {}).get(retry_key, 0)
@ -73,6 +74,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
raise ConfigEntryNotReady from err
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
if auth_user and auth_user.cloud_account:
ir.async_create_issue(
hass,
DOMAIN,
"cloud_user",
is_fixable=True,
is_persistent=False,
learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#local-user",
severity=IssueSeverity.ERROR,
translation_key="cloud_user",
data={"entry_id": entry.entry_id},
)
if nvr_info.version < MIN_REQUIRED_PROTECT_V:
_LOGGER.error(
OUTDATED_LOG_MESSAGE,

View file

@ -256,7 +256,8 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors = {}
nvr_data = None
try:
nvr_data = await protect.get_nvr()
bootstrap = await protect.get_bootstrap()
nvr_data = bootstrap.nvr
except NotAuthorized as ex:
_LOGGER.debug(ex)
errors[CONF_PASSWORD] = "invalid_auth"
@ -272,6 +273,10 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
errors["base"] = "protect_version"
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
if auth_user and auth_user.cloud_account:
errors["base"] = "cloud_user"
return nvr_data, errors
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:

View file

@ -20,7 +20,7 @@ from .utils import async_create_api_client
_LOGGER = logging.getLogger(__name__)
class EAConfirm(RepairsFlow):
class ProtectRepair(RepairsFlow):
"""Handler for an issue fixing flow."""
_api: ProtectApiClient
@ -34,14 +34,20 @@ class EAConfirm(RepairsFlow):
super().__init__()
@callback
def _async_get_placeholders(self) -> dict[str, str] | None:
def _async_get_placeholders(self) -> dict[str, str]:
issue_registry = async_get_issue_registry(self.hass)
description_placeholders = None
description_placeholders = {}
if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
description_placeholders = issue.translation_placeholders
description_placeholders = issue.translation_placeholders or {}
if issue.learn_more_url:
description_placeholders["learn_more"] = issue.learn_more_url
return description_placeholders
class EAConfirm(ProtectRepair):
"""Handler for an issue fixing flow."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
@ -85,6 +91,33 @@ class EAConfirm(RepairsFlow):
)
class CloudAccount(ProtectRepair):
"""Handler for an issue fixing flow."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
if user_input is None:
placeholders = self._async_get_placeholders()
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=placeholders,
)
self._entry.async_start_reauth(self.hass)
return self.async_create_entry(data={})
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@ -96,4 +129,9 @@ async def async_create_fix_flow(
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
api = async_create_api_client(hass, entry)
return EAConfirm(api, entry)
elif data is not None and issue_id == "cloud_user":
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
api = async_create_api_client(hass, entry)
return CloudAccount(api, entry)
return ConfirmRepairFlow()

View file

@ -37,7 +37,8 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry."
"protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.",
"cloud_user": "Ubiquiti Cloud users are not Supported. Please use a Local only user."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@ -78,6 +79,17 @@
"ea_setup_failed": {
"title": "Setup error using Early Access version",
"description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please [downgrade to a stable version](https://www.home-assistant.io/integrations/unifiprotect#downgrading-unifi-protect) of UniFi Protect to continue using the integration.\n\nError: {error}"
},
"cloud_user": {
"title": "Ubiquiti Cloud Users are not Supported",
"fix_flow": {
"step": {
"confirm": {
"title": "Ubiquiti Cloud Users are not Supported",
"description": "Starting on July 22nd, 2024, Ubiquiti will require all cloud users to enroll in multi-factor authentication (MFA), which is incompatible with Home Assistant.\n\nIt would be best to migrate to using a [local user]({learn_more}) as soon as possible to keep the integration working.\n\nConfirming this repair will trigger a re-authentication flow to enter the needed authentication credentials."
}
}
}
}
},
"entity": {

View file

@ -19,6 +19,7 @@ from pyunifiprotect.data import (
Bootstrap,
Camera,
Chime,
CloudAccount,
Doorlock,
Light,
Liveview,
@ -119,6 +120,7 @@ def mock_ufp_client(bootstrap: Bootstrap):
client.base_url = "https://127.0.0.1"
client.connection_host = IPv4Address("127.0.0.1")
client.get_nvr = AsyncMock(return_value=nvr)
client.get_bootstrap = AsyncMock(return_value=bootstrap)
client.update = AsyncMock(return_value=bootstrap)
client.async_disconnect_ws = AsyncMock()
return client
@ -345,3 +347,19 @@ def chime():
def fixed_now_fixture():
"""Return datetime object that will be consistent throughout test."""
return dt_util.utcnow()
@pytest.fixture(name="cloud_account")
def cloud_account() -> CloudAccount:
"""Return UI Cloud Account."""
return CloudAccount(
id="42",
first_name="Test",
last_name="User",
email="test@example.com",
user_id="42",
name="Test User",
location=None,
profile_img=None,
)

View file

@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient
from pyunifiprotect.data import NVR
from pyunifiprotect.data import NVR, Bootstrap, CloudAccount
from homeassistant import config_entries
from homeassistant.components import dhcp, ssdp
@ -57,7 +57,7 @@ UNIFI_DISCOVERY_DICT = asdict(UNIFI_DISCOVERY)
UNIFI_DISCOVERY_DICT_PARTIAL = asdict(UNIFI_DISCOVERY_PARTIAL)
async def test_form(hass: HomeAssistant, nvr: NVR) -> None:
async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -65,9 +65,10 @@ async def test_form(hass: HomeAssistant, nvr: NVR) -> None:
assert result["type"] == FlowResultType.FORM
assert not result["errors"]
bootstrap.nvr = nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
return_value=nvr,
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
), patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -99,15 +100,18 @@ async def test_form(hass: HomeAssistant, nvr: NVR) -> None:
assert len(mock_setup.mock_calls) == 1
async def test_form_version_too_old(hass: HomeAssistant, old_nvr: NVR) -> None:
async def test_form_version_too_old(
hass: HomeAssistant, bootstrap: Bootstrap, old_nvr: NVR
) -> None:
"""Test we handle the version being too old."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
bootstrap.nvr = old_nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
return_value=old_nvr,
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -129,7 +133,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
)
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
):
result2 = await hass.config_entries.flow.async_configure(
@ -145,6 +149,34 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
assert result2["errors"] == {"password": "invalid_auth"}
async def test_form_cloud_user(
hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount
) -> None:
"""Test we handle cloud users."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
user = bootstrap.users[bootstrap.auth_user_id]
user.cloud_account = cloud_account
bootstrap.users[bootstrap.auth_user_id] = user
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cloud_user"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
@ -152,7 +184,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
)
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NvrError,
):
result2 = await hass.config_entries.flow.async_configure(
@ -168,7 +200,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None:
async def test_form_reauth_auth(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test we handle reauth auth."""
mock_config = MockConfigEntry(
domain=DOMAIN,
@ -200,7 +234,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None:
}
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
):
result2 = await hass.config_entries.flow.async_configure(
@ -215,9 +249,10 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None:
assert result2["errors"] == {"password": "invalid_auth"}
assert result2["step_id"] == "reauth_confirm"
bootstrap.nvr = nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
return_value=nvr,
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
), patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
@ -313,7 +348,7 @@ async def test_discovered_by_ssdp_or_dhcp(
async def test_discovered_by_unifi_discovery_direct_connect(
hass: HomeAssistant, nvr: NVR
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery."""
@ -335,9 +370,10 @@ async def test_discovered_by_unifi_discovery_direct_connect(
assert not result["errors"]
bootstrap.nvr = nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
return_value=nvr,
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
), patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -501,7 +537,9 @@ async def test_discovered_host_not_updated_if_existing_is_a_hostname(
assert mock_config.data[CONF_HOST] == "a.hostname"
async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> None:
async def test_discovered_by_unifi_discovery(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery."""
with _patch_discovery():
@ -522,9 +560,10 @@ async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> N
assert not result["errors"]
bootstrap.nvr = nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
side_effect=[NotAuthorized, nvr],
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=[NotAuthorized, bootstrap],
), patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -556,7 +595,7 @@ async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> N
async def test_discovered_by_unifi_discovery_partial(
hass: HomeAssistant, nvr: NVR
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery partial."""
@ -578,9 +617,10 @@ async def test_discovered_by_unifi_discovery_partial(
assert not result["errors"]
bootstrap.nvr = nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
return_value=nvr,
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
), patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -710,7 +750,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_fails(
hass: HomeAssistant, nvr: NVR
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test we can still configure if the resolver fails."""
mock_config = MockConfigEntry(
@ -751,9 +791,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
assert not result["errors"]
bootstrap.nvr = nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
return_value=nvr,
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
), patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,

View file

@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
import aiohttp
from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient
from pyunifiprotect.data import NVR, Bootstrap, Light
from pyunifiprotect.data import NVR, Bootstrap, CloudAccount, Light
from homeassistant.components.unifiprotect.const import (
AUTH_RETRIES,
@ -132,7 +132,9 @@ async def test_setup_too_old(
) -> None:
"""Test setup of unifiprotect entry with too old of version of UniFi Protect."""
ufp.api.get_nvr.return_value = old_nvr
old_bootstrap = ufp.api.bootstrap.copy()
old_bootstrap.nvr = old_nvr
ufp.api.get_bootstrap.return_value = old_bootstrap
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
@ -140,6 +142,37 @@ async def test_setup_too_old(
assert not ufp.api.update.called
async def test_setup_cloud_account(
hass: HomeAssistant,
ufp: MockUFPFixture,
cloud_account: CloudAccount,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test setup of unifiprotect entry with cloud account."""
bootstrap = ufp.api.bootstrap
user = bootstrap.users[bootstrap.auth_user_id]
user.cloud_account = cloud_account
bootstrap.users[bootstrap.auth_user_id] = user
ufp.api.get_bootstrap.return_value = bootstrap
ws_client = await hass_ws_client(hass)
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
assert ufp.entry.state == ConfigEntryState.LOADED
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) > 0
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "cloud_user":
issue = i
assert issue is not None
async def test_setup_failed_update(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
"""Test setup of unifiprotect entry with failed update."""
@ -178,7 +211,7 @@ async def test_setup_failed_update_reauth(
async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
"""Test setup of unifiprotect entry with generic error."""
ufp.api.get_nvr = AsyncMock(side_effect=NvrError)
ufp.api.get_bootstrap = AsyncMock(side_effect=NvrError)
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
@ -189,7 +222,7 @@ async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture) -> N
async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
"""Test setup of unifiprotect entry with unauthorized error after multiple retries."""
ufp.api.get_nvr = AsyncMock(side_effect=NotAuthorized)
ufp.api.get_bootstrap = AsyncMock(side_effect=NotAuthorized)
await hass.config_entries.async_setup(ufp.entry.entry_id)
assert ufp.entry.state is ConfigEntryState.SETUP_RETRY

View file

@ -216,6 +216,7 @@ async def test_browse_media_root_multiple_consoles(
api2.api_path = "/api"
api2.base_url = "https://127.0.0.2"
api2.connection_host = IPv4Address("127.0.0.2")
api2.get_bootstrap = AsyncMock(return_value=bootstrap2)
api2.get_nvr = AsyncMock(return_value=bootstrap2.nvr)
api2.update = AsyncMock(return_value=bootstrap2)
api2.async_disconnect_ws = AsyncMock()

View file

@ -5,7 +5,7 @@ from copy import copy
from http import HTTPStatus
from unittest.mock import Mock
from pyunifiprotect.data import Version
from pyunifiprotect.data import CloudAccount, Version
from homeassistant.components.repairs.issue_handler import (
async_process_repairs_platforms,
@ -15,6 +15,7 @@ from homeassistant.components.repairs.websocket_api import (
RepairsFlowResourceView,
)
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.core import HomeAssistant
from .utils import MockUFPFixture, init_entry
@ -54,7 +55,10 @@ async def test_ea_warning_ignore(
data = await resp.json()
flow_id = data["flow_id"]
assert data["description_placeholders"] == {"version": str(version)}
assert data["description_placeholders"] == {
"learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access",
"version": str(version),
}
assert data["step_id"] == "start"
url = RepairsFlowResourceView.url.format(flow_id=flow_id)
@ -63,7 +67,10 @@ async def test_ea_warning_ignore(
data = await resp.json()
flow_id = data["flow_id"]
assert data["description_placeholders"] == {"version": str(version)}
assert data["description_placeholders"] == {
"learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access",
"version": str(version),
}
assert data["step_id"] == "confirm"
url = RepairsFlowResourceView.url.format(flow_id=flow_id)
@ -106,7 +113,10 @@ async def test_ea_warning_fix(
data = await resp.json()
flow_id = data["flow_id"]
assert data["description_placeholders"] == {"version": str(version)}
assert data["description_placeholders"] == {
"learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access",
"version": str(version),
}
assert data["step_id"] == "start"
new_nvr = copy(ufp.api.bootstrap.nvr)
@ -125,3 +135,50 @@ async def test_ea_warning_fix(
data = await resp.json()
assert data["type"] == "create_entry"
async def test_cloud_user_fix(
hass: HomeAssistant,
ufp: MockUFPFixture,
cloud_account: CloudAccount,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test EA warning is created if using prerelease version of Protect."""
ufp.api.bootstrap.nvr.version = Version("2.2.6")
user = ufp.api.bootstrap.users[ufp.api.bootstrap.auth_user_id]
user.cloud_account = cloud_account
ufp.api.bootstrap.users[ufp.api.bootstrap.auth_user_id] = user
await init_entry(hass, ufp, [])
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
client = await hass_client()
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) > 0
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "cloud_user":
issue = i
assert issue is not None
url = RepairsFlowIndexView.url
resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "cloud_user"})
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data["step_id"] == "confirm"
url = RepairsFlowResourceView.url.format(flow_id=flow_id)
resp = await client.post(url)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data["type"] == "create_entry"
await hass.async_block_till_done()
assert any(ufp.entry.async_get_active_flows(hass, {SOURCE_REAUTH}))