diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index 3d8ecf4a5e0..9eea047f9b7 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -4,10 +4,18 @@ from __future__ import annotations from airos.airos8 import AirOS8 -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator _PLATFORMS: list[Platform] = [ @@ -21,13 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo # By default airOS 8 comes with self-signed SSL certificates, # with no option in the web UI to change or upload a custom certificate. - session = async_get_clientsession(hass, verify_ssl=False) + session = async_get_clientsession( + hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] + ) airos_device = AirOS8( host=entry.data[CONF_HOST], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], session=session, + use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], ) coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) @@ -40,6 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo return True +async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Migrate old config entry.""" + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version == 1: + new_data = {**entry.data} + advanced_data = { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + } + new_data[SECTION_ADVANCED_SETTINGS] = advanced_data + + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=2, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index e66878221fe..f0e4b48a8cc 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -15,10 +15,17 @@ from airos.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS from .coordinator import AirOS8 _LOGGER = logging.getLogger(__name__) @@ -28,6 +35,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_HOST): str, vol.Required(CONF_USERNAME, default="ubnt"): str, vol.Required(CONF_PASSWORD): str, + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Required(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } + ), + {"collapsed": True}, + ), } ) @@ -36,6 +52,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, @@ -46,13 +63,17 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: # By default airOS 8 comes with self-signed SSL certificates, # with no option in the web UI to change or upload a custom certificate. - session = async_get_clientsession(self.hass, verify_ssl=False) + session = async_get_clientsession( + self.hass, + verify_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], + ) airos_device = AirOS8( host=user_input[CONF_HOST], username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], session=session, + use_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_SSL], ) try: await airos_device.login() diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py index f4be2594613..29a5f6a9e55 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -7,3 +7,8 @@ DOMAIN = "airos" SCAN_INTERVAL = timedelta(minutes=1) MANUFACTURER = "Ubiquiti" + +DEFAULT_VERIFY_SSL = False +DEFAULT_SSL = True + +SECTION_ADVANCED_SETTINGS = "advanced_settings" diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py index e54962110fc..0b1245694c1 100644 --- a/homeassistant/components/airos/entity.py +++ b/homeassistant/components/airos/entity.py @@ -2,11 +2,11 @@ from __future__ import annotations -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_SSL from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSDataUpdateCoordinator @@ -20,9 +20,14 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]): super().__init__(coordinator) airos_data = self.coordinator.data + url_schema = ( + "https" + if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] + else "http" + ) configuration_url: str | None = ( - f"https://{coordinator.config_entry.data[CONF_HOST]}" + f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}" ) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 53681292f50..a6e83aae869 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -12,6 +12,18 @@ "host": "IP address or hostname of the airOS device", "username": "Administrator username for the airOS device, normally 'ubnt'", "password": "Password configured through the UISP app or web interface" + }, + "sections": { + "advanced_settings": { + "data": { + "ssl": "Use HTTPS", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "ssl": "Whether the connection should be encrypted (required for most devices)", + "verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates" + } + } } } }, diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index a86eb8fd39b..8c341a670d2 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -1,7 +1,7 @@ """Common fixtures for the Ubiquiti airOS tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from airos.airos8 import AirOS8Data import pytest @@ -28,22 +28,26 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_airos_class() -> Generator[MagicMock]: + """Fixture to mock the AirOS class itself.""" + with ( + patch("homeassistant.components.airos.AirOS8", autospec=True) as mock_class, + patch("homeassistant.components.airos.config_flow.AirOS8", new=mock_class), + patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_class), + ): + yield mock_class + + @pytest.fixture def mock_airos_client( - request: pytest.FixtureRequest, ap_fixture: AirOS8Data + mock_airos_class: MagicMock, ap_fixture: AirOS8Data ) -> Generator[AsyncMock]: """Fixture to mock the AirOS API client.""" - with ( - patch( - "homeassistant.components.airos.config_flow.AirOS8", autospec=True - ) as mock_airos, - patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_airos), - patch("homeassistant.components.airos.AirOS8", new=mock_airos), - ): - client = mock_airos.return_value - client.status.return_value = ap_fixture - client.login.return_value = True - yield client + client = mock_airos_class.return_value + client.status.return_value = ap_fixture + client.login.return_value = True + return client @pytest.fixture diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index f4561ec6d99..4e94beae473 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -632,6 +632,10 @@ }), }), 'entry_data': dict({ + 'advanced_settings': dict({ + 'ssl': True, + 'verify_ssl': False, + }), 'host': '**REDACTED**', 'password': '**REDACTED**', 'username': 'ubnt', diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 212c80dfc2b..a502f9f2f3b 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -10,9 +10,15 @@ from airos.exceptions import ( ) import pytest -from homeassistant.components.airos.const import DOMAIN +from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -22,6 +28,10 @@ MOCK_CONFIG = { CONF_HOST: "1.1.1.1", CONF_USERNAME: "ubnt", CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, } @@ -33,7 +43,8 @@ async def test_form_creates_entry( ) -> None: """Test we get the form and create the appropriate entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py new file mode 100644 index 00000000000..30e2498d7d7 --- /dev/null +++ b/tests/components/airos/test_init.py @@ -0,0 +1,169 @@ +"""Test for airOS integration setup.""" + +from __future__ import annotations + +from unittest.mock import ANY, MagicMock + +from homeassistant.components.airos.const import ( + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, + SECTION_ADVANCED_SETTINGS, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_V1 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", +} + +MOCK_CONFIG_PLAIN = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, +} + +MOCK_CONFIG_V1_2 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, +} + + +async def test_setup_entry_with_default_ssl( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airos_client: MagicMock, + mock_airos_class: MagicMock, +) -> None: + """Test setting up a config entry with default SSL options.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_airos_class.assert_called_once_with( + host=mock_config_entry.data[CONF_HOST], + username=mock_config_entry.data[CONF_USERNAME], + password=mock_config_entry.data[CONF_PASSWORD], + session=ANY, + use_ssl=DEFAULT_SSL, + ) + + assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True + assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False + + +async def test_setup_entry_without_ssl( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_airos_class: MagicMock, +) -> None: + """Test setting up a config entry adjusted to plain HTTP.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_PLAIN, + entry_id="1", + unique_id="airos_device", + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + mock_airos_class.assert_called_once_with( + host=entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=ANY, + use_ssl=False, + ) + + assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False + assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False + + +async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1, + entry_id="1", + unique_id="airos_device", + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data == MOCK_CONFIG_V1_2 + + +async def test_migrate_future_return( + hass: HomeAssistant, + mock_airos_client: MagicMock, +) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1_2, + entry_id="1", + unique_id="airos_device", + version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup and unload config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED