Begin migrating unifiprotect to use the public API (#149126)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Raphael Hehl 2025-07-23 10:46:52 +02:00 committed by GitHub
parent 9a6ba225e4
commit 51a46a128c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 380 additions and 32 deletions

View File

@ -16,9 +16,13 @@ from uiprotect.exceptions import ClientError, NotAuthorized
from uiprotect.test_util.anonymize import anonymize_data # noqa: F401
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@ -33,7 +37,6 @@ from .const import (
DEVICES_THAT_ADOPT,
DOMAIN,
MIN_REQUIRED_PROTECT_V,
OUTDATED_LOG_MESSAGE,
PLATFORMS,
)
from .data import ProtectData, UFPConfigEntry
@ -69,6 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
"""Set up the UniFi Protect config entries."""
protect = async_create_api_client(hass, entry)
_LOGGER.debug("Connect to UniFi Protect")
@ -89,6 +93,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
bootstrap = protect.bootstrap
nvr_info = bootstrap.nvr
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
# Check if API key is missing
if not protect.is_api_key_set() and auth_user and nvr_info.can_write(auth_user):
try:
new_api_key = await protect.create_api_key(
name=f"Home Assistant ({hass.config.location_name})"
)
except NotAuthorized as err:
_LOGGER.error("Failed to create API key: %s", err)
else:
protect.set_api_key(new_api_key)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_API_KEY: new_api_key}
)
if not protect.is_api_key_set():
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_key_required",
)
if auth_user and auth_user.cloud_account:
ir.async_create_issue(
hass,
@ -103,12 +128,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
)
if nvr_info.version < MIN_REQUIRED_PROTECT_V:
_LOGGER.error(
OUTDATED_LOG_MESSAGE,
nvr_info.version,
MIN_REQUIRED_PROTECT_V,
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="protect_version",
translation_placeholders={
"current_version": str(nvr_info.version),
"min_version": str(MIN_REQUIRED_PROTECT_V),
},
)
return False
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac)

View File

@ -23,6 +23,7 @@ from homeassistant.config_entries import (
OptionsFlowWithReload,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_ID,
CONF_PASSWORD,
@ -214,6 +215,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
@ -247,6 +249,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
session = async_create_clientsession(
self.hass, cookie_jar=CookieJar(unsafe=True)
)
public_api_session = async_get_clientsession(self.hass)
host = user_input[CONF_HOST]
port = user_input.get(CONF_PORT, DEFAULT_PORT)
@ -254,10 +257,12 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
protect = ProtectApiClient(
session=session,
public_api_session=public_api_session,
host=host,
port=port,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
api_key=user_input[CONF_API_KEY],
verify_ssl=verify_ssl,
cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")),
config_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")),
@ -286,6 +291,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
if auth_user and auth_user.cloud_account:
errors["base"] = "cloud_user"
try:
await protect.get_meta_info()
except NotAuthorized as ex:
_LOGGER.debug(ex)
errors[CONF_API_KEY] = "invalid_auth"
except ClientError as ex:
_LOGGER.error(ex)
errors["base"] = "cannot_connect"
return nvr_data, errors
@ -318,12 +331,18 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
}
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={
"local_user_documentation_url": await async_local_user_documentation_url(
self.hass
),
},
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME, default=form_data.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
@ -366,6 +385,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,

View File

@ -52,7 +52,7 @@ DEVICES_THAT_ADOPT = {
DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR}
DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}
MIN_REQUIRED_PROTECT_V = Version("1.20.0")
MIN_REQUIRED_PROTECT_V = Version("6.0.0")
OUTDATED_LOG_MESSAGE = (
"You are running v%s of UniFi Protect. Minimum required version is v%s. Please"
" upgrade UniFi Protect and then retry"

View File

@ -10,19 +10,27 @@
"port": "[%key:common::config_flow::data::port%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"host": "Hostname or IP address of your UniFi Protect device."
"host": "Hostname or IP address of your UniFi Protect device.",
"api_key": "API key for your local user account."
}
},
"reauth_confirm": {
"title": "UniFi Protect reauth",
"description": "Your credentials or API key seem to be missing or invalid. For instructions on how to create a local user or generate a new API key, please refer to the documentation: {local_user_documentation_url}",
"data": {
"host": "IP/Host of UniFi Protect server",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "API key for your local user account.",
"username": "Username for your local (not cloud) user account."
}
},
"discovery_confirm": {
@ -30,14 +38,18 @@
"description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "API key for your local user account."
}
}
},
"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 v6.0.0. Please upgrade UniFi Protect and then retry.",
"cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead."
},
"abort": {
@ -669,5 +681,13 @@
}
}
}
},
"exceptions": {
"api_key_required": {
"message": "API key is required. Please reauthenticate this integration to provide an API key."
},
"protect_version": {
"message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}."
}
}
}

View File

@ -110,13 +110,16 @@ def async_create_api_client(
"""Create ProtectApiClient from config entry."""
session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True))
public_api_session = async_create_clientsession(hass)
return ProtectApiClient(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
api_key=entry.data.get("api_key"),
verify_ssl=entry.data[CONF_VERIFY_SSL],
session=session,
public_api_session=public_api_session,
subscribed_models=DEVICES_FOR_SUBSCRIBE,
override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False),
ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False),

View File

@ -32,6 +32,7 @@ from uiprotect.data import (
from uiprotect.websocket import WebsocketState
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
@ -68,6 +69,7 @@ def mock_ufp_config_entry():
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
CONF_API_KEY: "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,

View File

@ -5,7 +5,7 @@
"canAutoUpdate": true,
"isStatsGatheringEnabled": true,
"timezone": "America/New_York",
"version": "2.2.6",
"version": "6.0.0",
"ucoreVersion": "2.3.26",
"firmwareVersion": "2.3.10",
"uiVersion": null,

View File

@ -74,6 +74,10 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -89,6 +93,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
@ -99,6 +104,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
@ -116,9 +122,15 @@ async def test_form_version_too_old(
)
bootstrap.nvr = old_nvr
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -126,6 +138,7 @@ async def test_form_version_too_old(
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
@ -133,15 +146,21 @@ async def test_form_version_too_old(
assert result2["errors"] == {"base": "protect_version"}
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
async def test_form_invalid_auth_password(hass: HomeAssistant) -> None:
"""Test we handle invalid auth password."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -149,6 +168,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
@ -156,6 +176,38 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
assert result2["errors"] == {"password": "invalid_auth"}
async def test_form_invalid_auth_api_key(
hass: HomeAssistant, bootstrap: Bootstrap
) -> None:
"""Test we handle invalid auth api key."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
side_effect=NotAuthorized,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"api_key": "invalid_auth"}
async def test_form_cloud_user(
hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount
) -> None:
@ -167,9 +219,15 @@ async def test_form_cloud_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,
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -177,6 +235,7 @@ async def test_form_cloud_user(
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
@ -190,9 +249,15 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NvrError,
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NvrError,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
side_effect=NvrError,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -200,6 +265,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
@ -217,6 +283,7 @@ async def test_form_reauth_auth(
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
@ -234,15 +301,22 @@ async def test_form_reauth_auth(
"name": "Mock Title",
}
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
@ -260,12 +334,17 @@ async def test_form_reauth_auth(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
"username": "test-username",
"password": "new-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
@ -283,6 +362,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
@ -383,6 +463,10 @@ async def test_discovered_by_unifi_discovery_direct_connect(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -397,6 +481,7 @@ async def test_discovered_by_unifi_discovery_direct_connect(
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
@ -407,6 +492,7 @@ async def test_discovered_by_unifi_discovery_direct_connect(
"host": DIRECT_CONNECT_DOMAIN,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -425,6 +511,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated(
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -583,6 +670,10 @@ async def test_discovered_by_unifi_discovery(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=[NotAuthorized, bootstrap],
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -597,6 +688,7 @@ async def test_discovered_by_unifi_discovery(
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
@ -607,6 +699,7 @@ async def test_discovered_by_unifi_discovery(
"host": DEVICE_IP_ADDRESS,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
@ -644,6 +737,10 @@ async def test_discovered_by_unifi_discovery_partial(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -658,6 +755,7 @@ async def test_discovered_by_unifi_discovery_partial(
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
@ -668,6 +766,7 @@ async def test_discovered_by_unifi_discovery_partial(
"host": DEVICE_IP_ADDRESS,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
@ -686,6 +785,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"host": DIRECT_CONNECT_DOMAIN,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -716,6 +816,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"host": "127.0.0.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -746,6 +847,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -787,6 +889,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -827,6 +930,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
@ -841,6 +948,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
@ -851,6 +959,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"host": "nomatchsameip.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
@ -869,6 +978,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,

View File

@ -18,6 +18,7 @@ from homeassistant.components.unifiprotect.data import (
async_ufp_instance_for_config_entry_ids,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
@ -29,6 +30,19 @@ from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@pytest.fixture
def mock_user_can_write_nvr(request: pytest.FixtureRequest, ufp: MockUFPFixture):
"""Fixture to mock can_write method on NVR objects with indirect parametrization."""
can_write_result = getattr(request, "param", True)
original_can_write = ufp.api.bootstrap.nvr.can_write
mock_can_write = Mock(return_value=can_write_result)
object.__setattr__(ufp.api.bootstrap.nvr, "can_write", mock_can_write)
try:
yield mock_can_write
finally:
object.__setattr__(ufp.api.bootstrap.nvr, "can_write", original_can_write)
async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
"""Test working setup of unifiprotect entry."""
@ -68,6 +82,7 @@ async def test_setup_multiple(
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
CONF_API_KEY: "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
@ -331,6 +346,112 @@ async def test_async_ufp_instance_for_config_entry_ids(
assert result == expected_result
@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True)
async def test_setup_creates_api_key_when_missing(
hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock
) -> None:
"""Test that API key is created when missing and user has write permissions."""
# Setup: API key is not set initially, user has write permissions
ufp.api.is_api_key_set.return_value = False
ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123")
# Mock set_api_key to update is_api_key_set return value when called
def set_api_key_side_effect(key):
ufp.api.is_api_key_set.return_value = True
ufp.api.set_api_key.side_effect = set_api_key_side_effect
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
# Verify API key was created and set
ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)")
ufp.api.set_api_key.assert_called_once_with("new-api-key-123")
# Verify config entry was updated with new API key
assert ufp.entry.data[CONF_API_KEY] == "new-api-key-123"
assert ufp.entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize("mock_user_can_write_nvr", [False], indirect=True)
async def test_setup_skips_api_key_creation_when_no_write_permission(
hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock
) -> None:
"""Test that API key creation is skipped when user has no write permissions."""
# Setup: API key is not set, user has no write permissions
ufp.api.is_api_key_set.return_value = False
# Should fail with auth error since no API key and can't create one
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
# Verify API key creation was not attempted
ufp.api.create_api_key.assert_not_called()
ufp.api.set_api_key.assert_not_called()
@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True)
async def test_setup_handles_api_key_creation_failure(
hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock
) -> None:
"""Test handling of API key creation failure."""
# Setup: API key is not set, user has write permissions, but creation fails
ufp.api.is_api_key_set.return_value = False
ufp.api.create_api_key = AsyncMock(
side_effect=NotAuthorized("Failed to create API key")
)
# Should fail with auth error due to API key creation failure
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
# Verify API key creation was attempted but set_api_key was not called
ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)")
ufp.api.set_api_key.assert_not_called()
async def test_setup_with_existing_api_key(
hass: HomeAssistant, ufp: MockUFPFixture
) -> None:
"""Test setup when API key is already set."""
# Setup: API key is already set
ufp.api.is_api_key_set.return_value = True
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
assert ufp.entry.state is ConfigEntryState.LOADED
# Verify API key creation was not attempted
ufp.api.create_api_key.assert_not_called()
ufp.api.set_api_key.assert_not_called()
@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True)
async def test_setup_api_key_creation_returns_none(
hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock
) -> None:
"""Test handling when API key creation returns None."""
# Setup: API key is not set, creation returns None (empty response)
# set_api_key will be called with None but is_api_key_set will still be False
ufp.api.is_api_key_set.return_value = False
ufp.api.create_api_key = AsyncMock(return_value=None)
# Should fail with auth error since API key creation returned None
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
# Verify API key creation was attempted and set_api_key was called with None
ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)")
ufp.api.set_api_key.assert_called_once_with(None)
async def test_migrate_entry_version_2(hass: HomeAssistant) -> None:
"""Test remove CONF_ALLOW_EA from options while migrating a 1 config entry to 2."""
with (
@ -350,3 +471,47 @@ async def test_migrate_entry_version_2(hass: HomeAssistant) -> None:
assert entry.version == 2
assert entry.options.get(CONF_ALLOW_EA) is None
assert entry.unique_id == "123456"
async def test_setup_skips_api_key_creation_when_no_auth_user(
hass: HomeAssistant, ufp: MockUFPFixture
) -> None:
"""Test that API key creation is skipped when auth_user is None."""
# Setup: API key is not set, auth_user is None
ufp.api.is_api_key_set.return_value = False
# Mock the users dictionary to return None for any user ID
with patch.dict(ufp.api.bootstrap.users, {}, clear=True):
# Should fail with auth error since no API key and no auth user to create one
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
# Verify API key creation was not attempted
ufp.api.create_api_key.assert_not_called()
ufp.api.set_api_key.assert_not_called()
@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True)
async def test_setup_fails_when_api_key_still_missing_after_creation(
hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock
) -> None:
"""Test that setup fails when API key is still missing after creation attempts."""
# Setup: API key is not set and remains not set even after attempts
ufp.api.is_api_key_set.return_value = False # type: ignore[attr-defined]
ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") # type: ignore[method-assign]
ufp.api.set_api_key = Mock() # type: ignore[method-assign] # Mock this but API key still won't be "set"
# Setup should fail since API key is still not set after creation
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
# Verify entry is in setup error state (which will trigger reauth automatically)
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
# Verify API key creation was attempted
ufp.api.create_api_key.assert_called_once_with( # type: ignore[attr-defined]
name="Home Assistant (test home)"
)
ufp.api.set_api_key.assert_called_once_with("new-api-key-123") # type: ignore[attr-defined]

View File

@ -234,6 +234,7 @@ async def test_browse_media_root_multiple_consoles(
"host": "1.1.1.2",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect2",
"port": 443,
"verify_ssl": False,