Files
core/tests/components/unifiprotect/test_config_flow.py
T

2762 lines
91 KiB
Python

"""Test the UniFi Protect config flow."""
from collections.abc import Callable
from dataclasses import asdict
import socket
from unittest.mock import AsyncMock, Mock, patch
import pytest
from uiprotect import NotAuthorized, NvrError, ProtectApiClient
from uiprotect.data import NVR, Bootstrap, CloudAccount, Version
from uiprotect.data.public_devices import PublicNVR
from uiprotect.exceptions import ClientError
from homeassistant import config_entries
from homeassistant.components.unifiprotect.const import (
CONF_ALL_UPDATES,
CONF_CONNECTION_MODE,
CONF_DISABLE_RTSP,
CONF_OVERRIDE_CHOST,
CONNECTION_MODE_API_KEY_ONLY,
DOMAIN,
)
from homeassistant.components.unifiprotect.utils import _async_unifi_mac_from_hass
from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
DEVICE_HOSTNAME,
DEVICE_IP_ADDRESS,
DEVICE_MAC_ADDRESS,
DIRECT_CONNECT_DOMAIN,
UNIFI_DISCOVERY,
UNIFI_DISCOVERY_PARTIAL,
)
from .conftest import (
DEFAULT_API_KEY,
DEFAULT_HOST,
DEFAULT_PASSWORD,
DEFAULT_PORT,
DEFAULT_USERNAME,
DEFAULT_VERIFY_SSL,
MAC_ADDR,
)
from tests.common import MockConfigEntry
# Base user input without credentials (for tests that override them)
BASE_USER_INPUT = {
CONF_HOST: DEFAULT_HOST,
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_USERNAME: DEFAULT_USERNAME,
}
# Common user input for reconfigure flow tests
RECONFIGURE_USER_INPUT = {
**BASE_USER_INPUT,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
}
UNIFI_DISCOVERY_DICT = asdict(UNIFI_DISCOVERY)
UNIFI_DISCOVERY_DICT_PARTIAL = asdict(UNIFI_DISCOVERY_PARTIAL)
# Name of the NVR fixture, which the API-key flow uses as the entry title.
NVR_NAME = "UnifiProtect"
async def _advance_menu(
hass: HomeAssistant, result: ConfigFlowResult, next_step_id: str
) -> ConfigFlowResult:
"""Advance a menu step to the chosen next step's form."""
assert result["type"] is FlowResultType.MENU
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": next_step_id}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == next_step_id
return result
async def _start_full_flow(hass: HomeAssistant) -> ConfigFlowResult:
"""Advance the user menu to the full-access step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
return await _advance_menu(hass, result, "full")
async def _complete_reconfigure_flow(
hass: HomeAssistant,
flow_id: str,
nvr: NVR,
bootstrap: Bootstrap,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
) -> ConfigFlowResult:
"""Complete a reconfigure flow to terminal state after an error.
Sets up mocks for successful completion and returns the result.
Caller should assert the expected terminal state.
"""
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
mock_api_bootstrap.side_effect = None
mock_api_bootstrap.return_value = bootstrap
mock_api_meta_info.side_effect = None
result = await hass.config_entries.flow.async_configure(
flow_id,
RECONFIGURE_USER_INPUT,
)
await hass.async_block_till_done()
return result
async def test_user_flow(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None:
"""Test successful user flow creates config entry."""
result = await _start_full_flow(hass)
assert not result["errors"]
bootstrap.nvr = nvr
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,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock_setup_entry,
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
):
result = 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",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "UnifiProtect"
assert result["data"] == {
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
}
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_setup.mock_calls) == 1
@pytest.mark.parametrize("version", ["1.19.0", "7.0.107"])
async def test_form_version_too_old(
hass: HomeAssistant,
bootstrap: Bootstrap,
old_nvr: NVR,
nvr: NVR,
version: str,
mock_setup: None,
) -> None:
"""Test we handle the version being too old and can recover."""
result = await _start_full_flow(hass)
old_nvr.version = Version(version)
bootstrap.nvr = old_nvr
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,
),
):
result = 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 result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "protect_version"}
# Now test recovery with valid version
bootstrap.nvr = nvr
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,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": DEFAULT_HOST,
"username": DEFAULT_USERNAME,
"password": DEFAULT_PASSWORD,
"api_key": DEFAULT_API_KEY,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
async def test_form_invalid_auth_password(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR, mock_setup: None
) -> None:
"""Test we handle invalid auth password and can recover."""
result = await _start_full_flow(hass)
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,
),
):
result = 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 result["type"] is FlowResultType.FORM
assert result["errors"] == {"password": "invalid_auth"}
# Now test recovery with valid credentials
bootstrap.nvr = nvr
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,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": DEFAULT_HOST,
"username": DEFAULT_USERNAME,
"password": "correct-password",
"api_key": DEFAULT_API_KEY,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
async def test_form_invalid_auth_api_key(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR, mock_setup: None
) -> None:
"""Test we handle invalid auth api key and can recover."""
result = await _start_full_flow(hass)
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,
),
):
result = 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 result["type"] is FlowResultType.FORM
assert result["errors"] == {"api_key": "invalid_auth"}
# Now test recovery with valid API key
bootstrap.nvr = nvr
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,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": DEFAULT_HOST,
"username": DEFAULT_USERNAME,
"password": DEFAULT_PASSWORD,
"api_key": "correct-api-key",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
async def test_form_cloud_user(
hass: HomeAssistant,
bootstrap: Bootstrap,
cloud_account: CloudAccount,
nvr: NVR,
mock_setup: None,
) -> None:
"""Test we handle cloud users and can recover with local user."""
result = await _start_full_flow(hass)
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,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result = 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 result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cloud_user"}
# Now test recovery with local user
user.cloud_account = None
bootstrap.users[bootstrap.auth_user_id] = user
bootstrap.nvr = nvr
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,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": DEFAULT_HOST,
"username": "local-username",
"password": DEFAULT_PASSWORD,
"api_key": DEFAULT_API_KEY,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
async def test_form_cannot_connect(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR, mock_setup: None
) -> None:
"""Test we handle cannot connect error and can recover."""
result = await _start_full_flow(hass)
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,
),
):
result = 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 result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Now test recovery when connection works
bootstrap.nvr = nvr
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,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": DEFAULT_HOST,
"username": DEFAULT_USERNAME,
"password": DEFAULT_PASSWORD,
"api_key": DEFAULT_API_KEY,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
async def test_form_reauth_auth(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
) -> None:
"""Test we handle reauth auth."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert flows[0]["context"]["title_placeholders"] == {
"ip_address": "1.1.1.1",
"name": "Mock Title",
}
# Verify that non-sensitive fields are pre-filled and sensitive fields are not
# The data_schema will have been created with add_suggested_values_to_schema
# We can't easily verify the suggested values, but we can verify the flow works
# and that when only providing new credentials, the old non-sensitive data is kept
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,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"password": "invalid_auth"}
assert result["step_id"] == "reauth_confirm"
bootstrap.nvr = nvr
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
),
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "new-password",
"api_key": "test-api-key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert len(mock_setup.mock_calls) == 1
# Verify that non-sensitive data was preserved when only credentials were updated
assert ufp_reauth_entry.data[CONF_HOST] == "1.1.1.1"
assert ufp_reauth_entry.data[CONF_PORT] == 443
assert ufp_reauth_entry.data[CONF_VERIFY_SSL] is False
assert ufp_reauth_entry.data[CONF_USERNAME] == "test-username"
assert ufp_reauth_entry.data[CONF_PASSWORD] == "new-password"
assert ufp_reauth_entry.data[CONF_API_KEY] == "test-api-key"
async def test_form_options(
hass: HomeAssistant,
ufp_config_entry: MockConfigEntry,
ufp_client: ProtectApiClient,
) -> None:
"""Test we handle options flows."""
ufp_config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.unifiprotect.utils.ProtectApiClient"
) as mock_api,
):
mock_api.return_value = ufp_client
await hass.config_entries.async_setup(ufp_config_entry.entry_id)
await hass.async_block_till_done()
assert ufp_config_entry.state is ConfigEntryState.LOADED
result = await hass.config_entries.options.async_init(ufp_config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{
CONF_DISABLE_RTSP: True,
CONF_ALL_UPDATES: True,
CONF_OVERRIDE_CHOST: True,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"all_updates": True,
"disable_rtsp": True,
"override_connection_host": True,
"max_media": 1000,
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(ufp_config_entry.entry_id)
async def test_discovered_by_unifi_discovery_direct_connect(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert flows[0]["context"]["title_placeholders"] == {
"ip_address": DEVICE_IP_ADDRESS,
"name": DEVICE_HOSTNAME,
}
assert not result["errors"]
bootstrap.nvr = nvr
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,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock_setup_entry,
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "UnifiProtect"
assert result["data"] == {
"host": DIRECT_CONNECT_DOMAIN,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
}
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_setup.mock_calls) == 1
async def test_discovered_by_unifi_discovery_direct_connect_updated(
hass: HomeAssistant,
) -> None:
"""Test a discovery from unifi-discovery updates the direct connect host."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "y.ui.direct",
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: True,
},
version=2,
unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(),
)
mock_config.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config.data[CONF_HOST] == DIRECT_CONNECT_DOMAIN
async def test_discovered_by_unifi_discovery_dc_updated_but_not_using_dc(
hass: HomeAssistant,
) -> None:
"""Test discovery updates the host but not direct connect if not in use."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "1.2.2.2",
"username": "test-username",
"password": "test-password",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
},
version=2,
unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(),
)
mock_config.add_to_hass(hass)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.async_console_is_alive",
return_value=False,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config.data[CONF_HOST] == "127.0.0.1"
async def test_discovered_by_unifi_discovery_no_update_ip_when_console_online(
hass: HomeAssistant,
) -> None:
"""Test discovery does not update IP unless old console is offline."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "1.2.2.2",
"username": "test-username",
"password": "test-password",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
},
version=2,
unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(),
)
mock_config.add_to_hass(hass)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.async_console_is_alive",
return_value=True,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config.data[CONF_HOST] == "1.2.2.2"
async def test_discovered_host_not_updated_if_existing_is_a_hostname(
hass: HomeAssistant,
) -> None:
"""Test we only update the host if its an ip address from discovery."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "a.hostname",
"username": "test-username",
"password": "test-password",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
},
unique_id=DEVICE_MAC_ADDRESS.upper().replace(":", ""),
)
mock_config.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config.data[CONF_HOST] == "a.hostname"
async def test_discovered_by_unifi_discovery(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert flows[0]["context"]["title_placeholders"] == {
"ip_address": DEVICE_IP_ADDRESS,
"name": DEVICE_HOSTNAME,
}
assert not result["errors"]
bootstrap.nvr = nvr
with (
patch(
"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,
) as mock_setup_entry,
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "UnifiProtect"
assert result["data"] == {
"host": DEVICE_IP_ADDRESS,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
}
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_setup.mock_calls) == 1
async def test_discovered_by_unifi_discovery_partial(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery partial."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT_PARTIAL,
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert flows[0]["context"]["title_placeholders"] == {
"ip_address": DEVICE_IP_ADDRESS,
"name": "NVR DDEEFF",
}
assert not result["errors"]
bootstrap.nvr = nvr
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,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock_setup_entry,
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "UnifiProtect"
assert result["data"] == {
"host": DEVICE_IP_ADDRESS,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
}
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_setup.mock_calls) == 1
@pytest.mark.parametrize(
("overrides", "expected_name"),
[
(
{"name": "Front Gate", "hostname": "unvr", "product_name": "UNVR"},
"Front Gate",
),
({"name": None, "hostname": "unvr", "product_name": "UNVR"}, "unvr"),
(
{"name": None, "hostname": None, "product_name": "Dream Machine"},
"Dream Machine",
),
],
ids=["console-name", "hostname", "product-name"],
)
async def test_discovery_name_resolution(
hass: HomeAssistant, overrides: dict[str, str | None], expected_name: str
) -> None:
"""Test the discovery title prefers the console name over raw platform codes."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={**UNIFI_DISCOVERY_DICT, **overrides},
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert flows[0]["context"]["title_placeholders"]["name"] == expected_name
async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface(
hass: HomeAssistant,
) -> None:
"""Test a discovery from unifi-discovery from an alternate interface."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": DIRECT_CONNECT_DOMAIN,
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
},
unique_id="FFFFFFAAAAAA",
)
mock_config.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovered_by_unifi_discovery_dc_different_interface_ip_matches(
hass: HomeAssistant,
) -> None:
"""Test discovery from alternate interface when the IP matches."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "127.0.0.1",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
},
unique_id="FFFFFFAAAAAA",
)
mock_config.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovered_by_unifi_discovery_dc_different_interface_resolver(
hass: HomeAssistant,
) -> None:
"""Test discovery from alternate interface when direct connect resolves."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
},
unique_id="FFFFFFAAAAAA",
)
mock_config.add_to_hass(hass)
other_ip_dict = UNIFI_DISCOVERY_DICT.copy()
other_ip_dict["source_ip"] = "127.0.0.1"
other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct"
with (
patch.object(
hass.loop,
"getaddrinfo",
return_value=[(socket.AF_INET, None, None, None, ("127.0.0.1", 443))],
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=other_ip_dict,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovered_by_unifi_discovery_dc_different_interface_resolver_fails(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test we can still configure when the resolver fails."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
},
unique_id="FFFFFFAAAAAA",
)
mock_config.runtime_data = Mock(async_stop=AsyncMock())
mock_config.add_to_hass(hass)
other_ip_dict = UNIFI_DISCOVERY_DICT.copy()
other_ip_dict["source_ip"] = "127.0.0.2"
other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct"
with (
patch.object(hass.loop, "getaddrinfo", side_effect=OSError),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=other_ip_dict,
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert flows[0]["context"]["title_placeholders"] == {
"ip_address": "127.0.0.2",
"name": "unvr",
}
assert not result["errors"]
bootstrap.nvr = nvr
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,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock_setup_entry,
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
) as mock_setup,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "UnifiProtect"
assert result["data"] == {
"host": "nomatchsameip.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
}
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
assert len(mock_setup_entry.mock_calls) == 2
assert len(mock_setup.mock_calls) == 1
async def test_discovered_by_unifi_discovery_dc_different_interface_resolver_no_result(
hass: HomeAssistant,
) -> None:
"""Test discovery from alternate interface when resolve has no result."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
"host": "y.ui.direct",
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
"id": "UnifiProtect",
"port": 443,
"verify_ssl": True,
},
unique_id="FFFFFFAAAAAA",
)
mock_config.add_to_hass(hass)
other_ip_dict = UNIFI_DISCOVERY_DICT.copy()
other_ip_dict["source_ip"] = "127.0.0.2"
other_ip_dict["direct_connect_domain"] = "y.ui.direct"
with patch.object(hass.loop, "getaddrinfo", return_value=[]):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=other_ip_dict,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovery_can_be_ignored(hass: HomeAssistant) -> None:
"""Test a discovery can be ignored."""
mock_config = MockConfigEntry(
domain=DOMAIN,
data={},
unique_id=DEVICE_MAC_ADDRESS.upper().replace(":", ""),
source=config_entries.SOURCE_IGNORE,
)
mock_config.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovery_with_both_ignored_and_normal_entry(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
) -> None:
"""Test discovery skips ignored entries with different MAC and completes."""
# Create ignored entry with different MAC - should be skipped (line 182)
# Use a completely different MAC that won't match discovery MAC (AABBCCDDEEFF)
other_mac = "11:22:33:44:55:66"
mock_ignored = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.2.3.4"},
unique_id=other_mac.replace(":", "").upper(), # 112233445566
source=config_entries.SOURCE_IGNORE,
)
mock_ignored.add_to_hass(hass)
# Create second ignored entry with different MAC - should also be skipped
other_mac2 = "22:33:44:55:66:77"
mock_ignored2 = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.2.3.5"},
unique_id=other_mac2.replace(":", "").upper(), # 223344556677
source=config_entries.SOURCE_IGNORE,
)
mock_ignored2.add_to_hass(hass)
# Discovery should:
# 1. Skip all ignored entries with different MAC (line 182 - continue)
# 2. Continue to discovery flow since no matching entries
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
# Flow continues to discovery step since no match found
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
# Complete the flow
bootstrap.nvr = nvr
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,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": DEFAULT_USERNAME,
"password": DEFAULT_PASSWORD,
"api_key": DEFAULT_API_KEY,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
async def test_discovery_confirm_fallback_to_ip(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
) -> None:
"""Test discovery confirm falls back to IP when direct connect fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
bootstrap.nvr = nvr
# First call (direct connect) fails, second call (IP) succeeds
mock_api_bootstrap.side_effect = [NvrError("Direct connect failed"), bootstrap]
with (
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"]["host"] == DEVICE_IP_ADDRESS
assert result["data"]["verify_ssl"] is False
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
async def test_discovery_confirm_with_api_key_error(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
) -> None:
"""Test discovery confirm preserves API key in form data on error."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["step_id"] == "discovery_confirm"
result = await _advance_menu(hass, result, "discovery_full")
# Both attempts fail to test form_data preservation with API key
mock_api_bootstrap.side_effect = NvrError("Connection failed")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_full"
assert result["errors"] == {"base": "cannot_connect"}
# Now provide working connection to complete the flow
bootstrap.nvr = nvr
mock_api_bootstrap.side_effect = None
mock_api_bootstrap.return_value = bootstrap
with (
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"api_key": "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == _async_unifi_mac_from_hass(
DEVICE_MAC_ADDRESS.upper().replace(":", "")
)
async def test_reconfigure(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Test with connection error
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
mock_api_bootstrap.side_effect = [NvrError, bootstrap]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**RECONFIGURE_USER_INPUT,
CONF_HOST: "1.1.1.2",
CONF_PASSWORD: "new-password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Test successful reconfiguration with matching NVR MAC
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**RECONFIGURE_USER_INPUT,
CONF_HOST: "1.1.1.2",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "new-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert ufp_reauth_entry.data[CONF_HOST] == "1.1.1.2"
assert ufp_reauth_entry.data[CONF_PASSWORD] == "new-password"
assert ufp_reauth_entry.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_different_nvr(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
) -> None:
"""Test reconfiguration flow aborts when trying to switch to different NVR."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Create a different NVR with different MAC (not matching MAC_ADDR)
different_nvr = nvr.model_copy()
different_nvr.mac = "112233445566" # Different from MAC_ADDR
bootstrap.nvr = different_nvr
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**BASE_USER_INPUT,
CONF_HOST: "2.2.2.2",
CONF_USERNAME: "different-username",
CONF_PASSWORD: "different-password",
CONF_API_KEY: "different-api-key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_nvr"
# Verify original config wasn't modified
assert ufp_reauth_entry.unique_id == _async_unifi_mac_from_hass(MAC_ADDR)
assert ufp_reauth_entry.data[CONF_HOST] == "1.1.1.1"
async def test_reconfigure_auth_error(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow with authentication error."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Test with password authentication error
mock_api_bootstrap.side_effect = NotAuthorized
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**RECONFIGURE_USER_INPUT, CONF_PASSWORD: "wrong-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_PASSWORD: "invalid_auth"}
# Now provide correct credentials to complete the flow
result = await _complete_reconfigure_flow(
hass, result["flow_id"], nvr, bootstrap, mock_api_bootstrap, mock_api_meta_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reconfigure_api_key_error(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow with API key error."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
# Test with API key authentication error
mock_api_meta_info.side_effect = NotAuthorized
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**RECONFIGURE_USER_INPUT, CONF_API_KEY: "wrong-api-key"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_API_KEY: "invalid_auth"}
# Now provide correct API key to complete the flow
result = await _complete_reconfigure_flow(
hass, result["flow_id"], nvr, bootstrap, mock_api_bootstrap, mock_api_meta_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reconfigure_cloud_user(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow with cloud user error."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Set up bootstrap with cloud user
bootstrap.nvr = nvr
bootstrap.users[bootstrap.auth_user_id].cloud_account = CloudAccount(
user_id="cloud_id",
id="cloud_id",
name="Cloud User",
email="user@example.com",
first_name="Test",
last_name="User",
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**BASE_USER_INPUT,
CONF_USERNAME: "cloud-username",
CONF_PASSWORD: "cloud-password",
CONF_API_KEY: DEFAULT_API_KEY,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cloud_user"}
# Now provide local user credentials to complete the flow
bootstrap.users[bootstrap.auth_user_id].cloud_account = None
result = await _complete_reconfigure_flow(
hass, result["flow_id"], nvr, bootstrap, mock_api_bootstrap, mock_api_meta_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reconfigure_outdated_version(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow with outdated protect version."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Set up NVR with outdated version
old_nvr = nvr.model_copy()
old_nvr.version = Version("5.0.0") # Below MIN_REQUIRED_PROTECT_V (7.2.105)
bootstrap.nvr = old_nvr
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
RECONFIGURE_USER_INPUT,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "protect_version"}
# Now provide updated NVR version to complete the flow
result = await _complete_reconfigure_flow(
hass, result["flow_id"], nvr, bootstrap, mock_api_bootstrap, mock_api_meta_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reconfigure_form_defaults(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry_alt: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow form has correct default values."""
ufp_reauth_entry_alt.add_to_hass(hass)
result = await ufp_reauth_entry_alt.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Verify that non-sensitive fields are pre-filled and sensitive fields are not
# The data_schema will have been created with add_suggested_values_to_schema
# We can't easily verify the suggested values, but we can verify the flow works
# and that when only providing new credentials, the old non-sensitive data is kept
# Use nvr with matching MAC
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
# Complete the flow to verify it works
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
CONF_PORT: 8443,
CONF_VERIFY_SSL: True,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "new-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Verify that all data was updated
entry = hass.config_entries.async_get_entry(ufp_reauth_entry_alt.entry_id)
assert entry.data[CONF_HOST] == "1.1.1.1"
assert entry.data[CONF_PORT] == 8443
assert entry.data[CONF_VERIFY_SSL] is True
assert entry.data[CONF_USERNAME] == "test-username"
assert entry.data[CONF_PASSWORD] == "new-password"
assert entry.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_same_nvr_updated_credentials(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration flow updating credentials for same NVR."""
# Use the NVR's actual MAC address
nvr_mac = _async_unifi_mac_from_hass(nvr.mac)
mock_config = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "old-username",
CONF_PASSWORD: "old-password",
CONF_API_KEY: "old-api-key",
"id": "UnifiProtect",
CONF_PORT: 443,
CONF_VERIFY_SSL: False,
},
unique_id=nvr_mac,
)
mock_config.add_to_hass(hass)
result = await mock_config.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
bootstrap.nvr = nvr
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "2.2.2.2",
CONF_PORT: 8443,
CONF_VERIFY_SSL: True,
CONF_USERNAME: "new-username",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "new-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Verify unique_id remains the same
assert mock_config.unique_id == nvr_mac
# Verify credentials were updated
assert mock_config.data[CONF_HOST] == "2.2.2.2"
assert mock_config.data[CONF_PORT] == 8443
assert mock_config.data[CONF_VERIFY_SSL] is True
assert mock_config.data[CONF_USERNAME] == "new-username"
assert mock_config.data[CONF_PASSWORD] == "new-password"
assert mock_config.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_empty_credentials_keeps_existing(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfiguration with empty credentials keeps existing values."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
# Submit with empty password and api_key - should keep existing values
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**BASE_USER_INPUT,
CONF_HOST: "2.2.2.2",
CONF_PASSWORD: "", # Empty - should keep existing
CONF_API_KEY: "", # Empty - should keep existing
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Verify existing credentials were preserved
assert ufp_reauth_entry.data[CONF_HOST] == "2.2.2.2"
assert ufp_reauth_entry.data[CONF_PASSWORD] == "test-password"
assert ufp_reauth_entry.data[CONF_API_KEY] == "test-api-key"
@pytest.mark.parametrize(
("input_credentials", "expected_credentials"),
[
# Only password updated, api_key kept
(
{CONF_PASSWORD: "new-password", CONF_API_KEY: ""},
{CONF_PASSWORD: "new-password", CONF_API_KEY: "test-api-key"},
),
# Only api_key updated, password kept
(
{CONF_PASSWORD: "", CONF_API_KEY: "new-api-key"},
{CONF_PASSWORD: "test-password", CONF_API_KEY: "new-api-key"},
),
# Both credentials updated
(
{CONF_PASSWORD: "new-password", CONF_API_KEY: "new-api-key"},
{CONF_PASSWORD: "new-password", CONF_API_KEY: "new-api-key"},
),
],
ids=["password_only", "api_key_only", "both_credentials"],
)
async def test_reconfigure_credential_update(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
input_credentials: dict[str, str],
expected_credentials: dict[str, str],
) -> None:
"""Test reconfiguration with various credential update scenarios."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**BASE_USER_INPUT, **input_credentials},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert ufp_reauth_entry.data[CONF_PASSWORD] == expected_credentials[CONF_PASSWORD]
assert ufp_reauth_entry.data[CONF_API_KEY] == expected_credentials[CONF_API_KEY]
async def test_reconfigure_invalid_existing_password_shows_error(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfigure shows password error when existing password is invalid."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
# Simulate invalid existing password (user leaves field empty)
mock_api_bootstrap.side_effect = NotAuthorized
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**BASE_USER_INPUT, CONF_PASSWORD: "", CONF_API_KEY: ""},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_PASSWORD: "invalid_auth"}
# Now provide correct credentials to complete the flow
result = await _complete_reconfigure_flow(
hass, result["flow_id"], nvr, bootstrap, mock_api_bootstrap, mock_api_meta_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reauth_empty_credentials_keeps_existing(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
) -> None:
"""Test reauth with empty credentials keeps existing values."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
with (
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
):
# Submit with empty credentials - should keep existing
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test-username",
CONF_PASSWORD: "", # Empty - should keep existing
CONF_API_KEY: "", # Empty - should keep existing
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
# Verify existing credentials were preserved
assert ufp_reauth_entry.data[CONF_PASSWORD] == "test-password"
assert ufp_reauth_entry.data[CONF_API_KEY] == "test-api-key"
@pytest.mark.parametrize(
("input_credentials", "expected_credentials"),
[
# Only password updated, api_key kept
(
{
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "",
},
{
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "test-api-key",
},
),
# Only api_key updated, password kept
(
{
CONF_USERNAME: "test-username",
CONF_PASSWORD: "",
CONF_API_KEY: "new-api-key",
},
{
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_API_KEY: "new-api-key",
},
),
# All credentials updated
(
{
CONF_USERNAME: "new-username",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "new-api-key",
},
{
CONF_USERNAME: "new-username",
CONF_PASSWORD: "new-password",
CONF_API_KEY: "new-api-key",
},
),
],
ids=["password_only", "api_key_only", "all_credentials"],
)
async def test_reauth_credential_update(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
input_credentials: dict[str, str],
expected_credentials: dict[str, str],
) -> None:
"""Test reauth with various credential update scenarios."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
with (
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
input_credentials,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert ufp_reauth_entry.data[CONF_USERNAME] == expected_credentials[CONF_USERNAME]
assert ufp_reauth_entry.data[CONF_PASSWORD] == expected_credentials[CONF_PASSWORD]
assert ufp_reauth_entry.data[CONF_API_KEY] == expected_credentials[CONF_API_KEY]
# Host should remain unchanged
assert ufp_reauth_entry.data[CONF_HOST] == "1.1.1.1"
async def test_reconfigure_clears_session_failure_continues(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Test reconfigure continues even if session clearing fails."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
# Simulate session clear failure - should still continue
with patch(
"homeassistant.components.unifiprotect.config_flow.async_create_session_client"
) as mock_create_client:
mock_protect = AsyncMock()
mock_protect.clear_session = AsyncMock(side_effect=Exception("Session error"))
mock_create_client.return_value = mock_protect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.2",
CONF_PORT: 443,
CONF_VERIFY_SSL: False,
CONF_USERNAME: "new-username", # Changed
CONF_PASSWORD: "new-password",
CONF_API_KEY: "new-api-key",
},
)
await hass.async_block_till_done()
# Should still succeed despite session clear failure
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert ufp_reauth_entry.data[CONF_USERNAME] == "new-username"
assert ufp_reauth_entry.data[CONF_PASSWORD] == "new-password"
async def test_form_api_key_client_error(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
mock_api_bootstrap: Mock,
) -> None:
"""Test that ClientError during API key validation shows cannot_connect error."""
result = await _start_full_flow(hass)
assert result["errors"] == {}
bootstrap.nvr = nvr
user_input = {
CONF_HOST: "1.1.1.1",
CONF_PORT: 443,
CONF_VERIFY_SSL: False,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_API_KEY: "test-api-key",
}
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
side_effect=ClientError("Connection failed"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_port_int_conversion(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
) -> None:
"""Test that port value is converted to int (NumberSelector returns float)."""
result = await _start_full_flow(hass)
bootstrap.nvr = nvr
with (
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
):
# NumberSelector returns float, verify int conversion works
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
CONF_PORT: 8443.0, # Float from NumberSelector
CONF_VERIFY_SSL: False,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_API_KEY: "test-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PORT] == 8443
assert isinstance(result["data"][CONF_PORT], int)
async def _start_api_key_flow(hass: HomeAssistant) -> ConfigFlowResult:
"""Advance the user menu to the API-key-only step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
return await _advance_menu(hass, result, "api_key")
def _meta_info(version: str = "7.2.105") -> Mock:
"""Build a MetaInfo-like mock with a parsed version."""
meta = Mock()
meta.version = Version(version)
return meta
def _public_nvr(mac: str | None, name: str = NVR_NAME) -> Mock:
"""Build the ``GET /v1/nvrs`` payload the API-key flow reads."""
public = Mock(spec=PublicNVR)
public.mac = mac
public.name = name
public.display_name = name
return public
async def test_api_key_flow(hass: HomeAssistant, nvr: NVR) -> None:
"""A valid API key creates a public-only config entry."""
result = await _start_api_key_flow(hass)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.1.1.1", "api_key": "test-api-key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"host": "1.1.1.1",
"port": 443,
"verify_ssl": False,
"api_key": "test-api-key",
"id": NVR_NAME,
"connection_mode": "api_key_only",
}
assert result["title"] == NVR_NAME
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("meta_effect", "nvr_effect", "expected_errors"),
[
pytest.param(
NotAuthorized, lambda: None, {"api_key": "invalid_auth"}, id="invalid_auth"
),
pytest.param(
ClientError, lambda: None, {"base": "cannot_connect"}, id="cannot_connect"
),
pytest.param(
lambda: _meta_info("1.0.0"),
lambda: None,
{"base": "protect_version"},
id="version_too_old",
),
pytest.param(
_meta_info,
lambda: _public_nvr(None),
{"base": "cannot_connect"},
id="mac_unresolved",
),
pytest.param(
_meta_info, ClientError, {"base": "cannot_connect"}, id="nvr_error"
),
pytest.param(
_meta_info,
NotAuthorized,
{"api_key": "invalid_auth"},
id="nvr_invalid_auth",
),
],
)
async def test_api_key_flow_errors(
hass: HomeAssistant,
nvr: NVR,
meta_effect: type[Exception] | Callable[[], Mock],
nvr_effect: type[Exception] | Callable[[], Mock | None],
expected_errors: dict[str, str],
) -> None:
"""Validation failures surface the matching form error and can recover."""
result = await _start_api_key_flow(hass)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
side_effect=meta_effect,
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
side_effect=nvr_effect,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.1.1.1", "api_key": "test-api-key"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == expected_errors
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.1.1.1", "api_key": "test-api-key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_CONNECTION_MODE] == CONNECTION_MODE_API_KEY_ONLY
async def test_reconfigure_flip_to_api_key(
hass: HomeAssistant,
ufp_reauth_entry: MockConfigEntry,
nvr: NVR,
mock_setup: None,
) -> None:
"""Reconfiguring to API-key-only keeps the credentials, flips the mode."""
ufp_reauth_entry.add_to_hass(hass)
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_api_key")
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": DEFAULT_HOST, "api_key": "new-api-key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# The mode field flips; the local-user credentials stay stored so
# switching back to full access is lossless.
assert ufp_reauth_entry.data[CONF_CONNECTION_MODE] == CONNECTION_MODE_API_KEY_ONLY
assert ufp_reauth_entry.data[CONF_USERNAME] == DEFAULT_USERNAME
assert ufp_reauth_entry.data[CONF_PASSWORD] == DEFAULT_PASSWORD
assert ufp_reauth_entry.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_flip_to_full(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_public_only_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Reconfiguring a public-only entry to full access stores local credentials.
The reverse of ``test_reconfigure_flip_to_api_key``: the API-key-only entry
gains a local user, so the reload switches to the full client and platform
set (validated by the full-access setup and public-only tests separately).
"""
ufp_public_only_entry.add_to_hass(hass)
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
result = await ufp_public_only_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**RECONFIGURE_USER_INPUT, CONF_API_KEY: "new-api-key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# The entry is now full access: the local user is stored alongside the key.
assert CONF_CONNECTION_MODE not in ufp_public_only_entry.data
assert ufp_public_only_entry.data[CONF_USERNAME] == DEFAULT_USERNAME
assert ufp_public_only_entry.data[CONF_PASSWORD] == DEFAULT_PASSWORD
assert ufp_public_only_entry.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_mode_round_trip(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""A full entry flips to API-key-only and back without re-entering credentials.
The kept credentials make the switch lossless: flipping back submits an
empty password and relies on the stored one.
"""
ufp_reauth_entry.add_to_hass(hass)
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_api_key")
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": DEFAULT_HOST, "api_key": "new-api-key"},
)
await hass.async_block_till_done()
assert result["reason"] == "reconfigure_successful"
assert ufp_reauth_entry.data[CONF_CONNECTION_MODE] == CONNECTION_MODE_API_KEY_ONLY
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**BASE_USER_INPUT,
CONF_PASSWORD: "",
CONF_API_KEY: "",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert CONF_CONNECTION_MODE not in ufp_reauth_entry.data
assert ufp_reauth_entry.data[CONF_USERNAME] == DEFAULT_USERNAME
assert ufp_reauth_entry.data[CONF_PASSWORD] == DEFAULT_PASSWORD
assert ufp_reauth_entry.data[CONF_API_KEY] == "new-api-key"
async def test_reconfigure_flip_to_full_clears_session(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_reauth_entry: MockConfigEntry,
mock_api_bootstrap: Mock,
mock_api_meta_info: Mock,
mock_setup: AsyncMock,
) -> None:
"""Flipping back to full access with a new password clears the old session.
The entry is still marked API-key-only while the clear runs, and a
public-only client cannot clear anything, so this has to go through a
full-access client.
"""
ufp_reauth_entry.add_to_hass(hass)
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_api_key")
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": DEFAULT_HOST, "api_key": "new-api-key"},
)
await hass.async_block_till_done()
assert ufp_reauth_entry.data[CONF_CONNECTION_MODE] == CONNECTION_MODE_API_KEY_ONLY
session_client = Mock(spec=ProtectApiClient)
session_client.clear_session = AsyncMock()
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await _advance_menu(hass, result, "reconfigure_full")
with patch(
"homeassistant.components.unifiprotect.config_flow.async_create_session_client",
return_value=session_client,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**BASE_USER_INPUT, CONF_PASSWORD: "new-password"},
)
await hass.async_block_till_done()
assert result["reason"] == "reconfigure_successful"
assert session_client.clear_session.called
async def test_reconfigure_flip_wrong_nvr(
hass: HomeAssistant,
ufp_reauth_entry: MockConfigEntry,
mock_setup: None,
) -> None:
"""Flipping to API-key-only aborts if it resolves a different NVR."""
ufp_reauth_entry.add_to_hass(hass)
result = await ufp_reauth_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "reconfigure_api_key"}
)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr("ffffffffffff", "Other NVR"),
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": DEFAULT_HOST, "api_key": "new-api-key"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_nvr"
async def test_reauth_public_only_api_key(
hass: HomeAssistant,
ufp_public_only_entry: MockConfigEntry,
mock_setup: None,
) -> None:
"""Reauth on a public-only entry asks only for a new API key.
A rejected key re-shows the form with an error and recovers from there.
"""
ufp_public_only_entry.add_to_hass(hass)
result = await ufp_public_only_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_api_key"
assert list(result["data_schema"].schema) == [CONF_API_KEY]
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
side_effect=NotAuthorized,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "bad-key"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_api_key"
assert result["errors"] == {CONF_API_KEY: "invalid_auth"}
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=MAC_ADDR,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "new-api-key"}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert ufp_public_only_entry.data[CONF_API_KEY] == "new-api-key"
# The entry must stay public-only; reauth never flips the mode.
assert CONF_USERNAME not in ufp_public_only_entry.data
async def test_reconfigure_full_from_public_only_missing_password(
hass: HomeAssistant,
bootstrap: Bootstrap,
nvr: NVR,
ufp_public_only_entry: MockConfigEntry,
mock_api_meta_info: Mock,
mock_setup: None,
) -> None:
"""Flipping public-only to full access with an empty password fails cleanly."""
ufp_public_only_entry.add_to_hass(hass)
nvr.mac = _async_unifi_mac_from_hass(MAC_ADDR)
bootstrap.nvr = nvr
result = await ufp_public_only_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "reconfigure_full"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_full"
# The entry has no stored password to fall back on: the login must fail
# as invalid_auth, not crash on the missing key.
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
side_effect=NotAuthorized,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: DEFAULT_HOST,
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_USERNAME: "new-user",
CONF_PASSWORD: "",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_PASSWORD: "invalid_auth"}
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: DEFAULT_HOST,
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_USERNAME: "new-user",
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert ufp_public_only_entry.data[CONF_PASSWORD] == DEFAULT_PASSWORD
async def test_discovery_api_key_flow(hass: HomeAssistant, nvr: NVR) -> None:
"""A discovered console can be set up with only an API key."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await _advance_menu(hass, result, "discovery_api_key")
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"api_key": "test-api-key"}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
# Direct-connect domain preferred (verified SSL), no local credentials.
assert result["data"] == {
"host": DIRECT_CONNECT_DOMAIN,
"port": 443,
"verify_ssl": True,
"api_key": "test-api-key",
"id": DEVICE_HOSTNAME,
"connection_mode": "api_key_only",
}
assert CONF_USERNAME not in result["data"]
# The unique id is the resolved NVR mac, not the discovery hardware address,
# so a later reconfigure never mistakes the console for a different NVR.
assert result["result"].unique_id == _async_unifi_mac_from_hass(nvr.mac)
assert len(mock_setup_entry.mock_calls) == 1
async def test_discovery_api_key_flow_already_configured(
hass: HomeAssistant, nvr: NVR
) -> None:
"""A discovered API-key setup aborts if the console is already configured."""
existing = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.1.1.1", CONF_API_KEY: "old", "id": "1.1.1.1"},
version=2,
unique_id=_async_unifi_mac_from_hass(nvr.mac),
)
existing.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery_api_key"}
)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
return_value=_public_nvr(nvr.mac),
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"api_key": "test-api-key"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth_public_only_survives_identity_resolution_failure(
hass: HomeAssistant,
ufp_public_only_entry: MockConfigEntry,
mock_setup: None,
) -> None:
"""Reauth never re-checks identity: the stored host pins the console.
Reauth validates only the key and version; it must not fetch the NVR at
all, so a transient failure there cannot block a valid new key, and a
divergent resolved mac cannot lock the user out.
"""
ufp_public_only_entry.add_to_hass(hass)
result = await ufp_public_only_entry.start_reauth_flow(hass)
with (
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=_meta_info(),
),
patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr_public",
side_effect=ClientError("/v1/nvrs unreachable"),
) as mock_get_nvr,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "new-api-key"}
)
await hass.async_block_till_done()
mock_get_nvr.assert_not_called()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert ufp_public_only_entry.data[CONF_API_KEY] == "new-api-key"
assert ufp_public_only_entry.unique_id == _async_unifi_mac_from_hass(MAC_ADDR)