From 51a46a128c4544a2fc78904e617615a7e67cddda Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:46:52 +0200 Subject: [PATCH] Begin migrating unifiprotect to use the public API (#149126) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 43 ++++- .../components/unifiprotect/config_flow.py | 20 +++ .../components/unifiprotect/const.py | 2 +- .../components/unifiprotect/strings.json | 30 +++- .../components/unifiprotect/utils.py | 3 + tests/components/unifiprotect/conftest.py | 2 + .../unifiprotect/fixtures/sample_nvr.json | 2 +- .../unifiprotect/test_config_flow.py | 144 +++++++++++++-- tests/components/unifiprotect/test_init.py | 165 ++++++++++++++++++ .../unifiprotect/test_media_source.py | 1 + 10 files changed, 380 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 440250d45a3..5fa9a85d341 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -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) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index c83b3f11010..0eab326d609 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -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, diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index d041b713125..f7138c24341 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -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" diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 23c662f5d71..f20b56d29e4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -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}." + } } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 61314346d32..9071a24eae6 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -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), diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index c49ade514bc..895ba62f81a 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -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, diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 13e93a8c2e7..dc841ab7a1e 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -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, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 880578719cd..a5cda887b4d 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -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, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3156327f1a5..b951d95fbdc 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -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] diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 61f9680bdbc..02d07bb1d4d 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -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,