diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 5052ffbaf1f..98152956fb5 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -1,14 +1,16 @@ """The Nettigo Air Monitor component.""" from __future__ import annotations +import asyncio import logging from typing import cast -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError +from aiohttp.client_exceptions import ClientConnectorError, ClientError import async_timeout from nettigo_air_monitor import ( ApiError, + AuthFailed, + ConnectionOptions, InvalidSensorData, NAMSensors, NettigoAirMonitor, @@ -16,13 +18,18 @@ from nettigo_air_monitor import ( from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( ATTR_SDS011, @@ -41,10 +48,20 @@ PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nettigo as config entry.""" host: str = entry.data[CONF_HOST] + username: str | None = entry.data.get(CONF_USERNAME) + password: str | None = entry.data.get(CONF_PASSWORD) websession = async_get_clientsession(hass) - coordinator = NAMDataUpdateCoordinator(hass, websession, host, entry.unique_id) + options = ConnectionOptions(host=host, username=username, password=password) + try: + nam = await NettigoAirMonitor.create(websession, options) + except AuthFailed as err: + raise ConfigEntryAuthFailed from err + except (ApiError, ClientError, ClientConnectorError, asyncio.TimeoutError) as err: + raise ConfigEntryNotReady from err + + coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) @@ -81,14 +98,12 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - session: ClientSession, - host: str, + nam: NettigoAirMonitor, unique_id: str | None, ) -> None: """Initialize.""" - self.host = host - self.nam = NettigoAirMonitor(session, host) self._unique_id = unique_id + self.nam = nam super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL @@ -102,6 +117,8 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): # get the data 4 times, so we use a longer than usual timeout here. async with async_timeout.timeout(30): data = await self.nam.async_update() + # We do not need to catch AuthFailed exception here because sensor data is + # always available without authorization. except (ApiError, ClientConnectorError, InvalidSensorData) as error: raise UpdateFailed(error) from error @@ -120,5 +137,5 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): name=DEFAULT_NAME, sw_version=self.nam.software_version, manufacturer=MANUFACTURER, - configuration_url=f"http://{self.host}/", + configuration_url=f"http://{self.nam.host}/", ) diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 86a30f95af6..0e98e4f7ef5 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,16 +3,23 @@ from __future__ import annotations import asyncio import logging -from typing import Any, cast +from typing import Any from aiohttp.client_exceptions import ClientConnectorError import async_timeout -from nettigo_air_monitor import ApiError, CannotGetMac, NettigoAirMonitor +from nettigo_air_monitor import ( + ApiError, + AuthFailed, + CannotGetMac, + ConnectionOptions, + NettigoAirMonitor, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import ATTR_NAME, CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -21,6 +28,23 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +AUTH_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +async def async_get_mac(hass: HomeAssistant, host: str, data: dict[str, Any]) -> str: + """Get device MAC address.""" + websession = async_get_clientsession(hass) + + options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + nam = await NettigoAirMonitor.create(websession, options) + # Device firmware uses synchronous code and doesn't respond to http queries + # when reading data from sensors. The nettigo-air-monitor library tries to get + # the data 4 times, so we use a longer than usual timeout here. + async with async_timeout.timeout(30): + return await nam.async_get_mac_address() + class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Nettigo Air Monitor.""" @@ -29,18 +53,22 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self.host: str | None = None + self.host: str + self.entry: config_entries.ConfigEntry async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: self.host = user_input[CONF_HOST] + try: - mac = await self._async_get_mac(cast(str, self.host)) + mac = await async_get_mac(self.hass, self.host, {}) + except AuthFailed: + return await self.async_step_credentials() except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" except CannotGetMac: @@ -49,36 +77,65 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) return self.async_create_entry( - title=cast(str, self.host), + title=self.host, data=user_input, ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=""): str, - } - ), + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the credentials step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + mac = await async_get_mac(self.hass, self.host, user_input) + except AuthFailed: + errors["base"] = "invalid_auth" + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except CannotGetMac: + return self.async_abort(reason="device_unsupported") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + return self.async_create_entry( + title=self.host, + data={**user_input, CONF_HOST: self.host}, + ) + + return self.async_show_form( + step_id="credentials", data_schema=AUTH_SCHEMA, errors=errors + ) + async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" self.host = discovery_info[CONF_HOST] + self.context["title_placeholders"] = {"host": self.host} # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: self.host}) try: - mac = await self._async_get_mac(self.host) + mac = await async_get_mac(self.hass, self.host, {}) + except AuthFailed: + return await self.async_step_credentials() except (ApiError, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMac: @@ -87,21 +144,17 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - self.context["title_placeholders"] = { - ATTR_NAME: discovery_info[ATTR_NAME].split(".")[0] - } - return await self.async_step_confirm_discovery() async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle discovery confirm.""" - errors: dict = {} + errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry( - title=cast(str, self.host), + title=self.host, data={CONF_HOST: self.host}, ) @@ -109,16 +162,39 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={CONF_HOST: self.host}, + description_placeholders={"host": self.host}, errors=errors, ) - async def _async_get_mac(self, host: str) -> str: - """Get device MAC address.""" - websession = async_get_clientsession(self.hass) - nam = NettigoAirMonitor(websession, host) - # Device firmware uses synchronous code and doesn't respond to http queries - # when reading data from sensors. The nettigo-air-monitor library tries to get - # the data 4 times, so we use a longer than usual timeout here. - async with async_timeout.timeout(30): - return await nam.async_get_mac_address() + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): + self.entry = entry + self.host = data[CONF_HOST] + self.context["title_placeholders"] = {"host": self.host} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + await async_get_mac(self.hass, self.host, user_input) + except (ApiError, AuthFailed, ClientConnectorError, asyncio.TimeoutError): + return self.async_abort(reason="reauth_unsuccessful") + else: + self.hass.config_entries.async_update_entry( + self.entry, data={**user_input, CONF_HOST: self.host} + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"host": self.host}, + data_schema=AUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 114fc4dd48d..1aab1cf4613 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -3,7 +3,7 @@ "name": "Nettigo Air Monitor", "documentation": "https://www.home-assistant.io/integrations/nam", "codeowners": ["@bieniu"], - "requirements": ["nettigo-air-monitor==1.1.1"], + "requirements": ["nettigo-air-monitor==1.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e8994a346bf..dab6eefb095 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name}", + "flow_title": "{host}", "step": { "user": { "description": "Set up Nettigo Air Monitor integration.", @@ -8,17 +8,34 @@ "host": "[%key:common::config_flow::data::host%]" } }, + "credentials": { + "description": "Please enter the username and password.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "description": "Please enter the correct username and password for host: {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, "confirm_discovery": { "description": "Do you want to set up Nettigo Air Monitor at {host}?" } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "device_unsupported": "The device is unsupported." + "device_unsupported": "The device is unsupported.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." } } } diff --git a/requirements_all.txt b/requirements_all.txt index 493fe9782bf..bfe22c2a1cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1056,7 +1056,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.1.1 +nettigo-air-monitor==1.2.1 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c08e1aded43..0c7fe4f9dbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -642,7 +642,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.1.1 +nettigo-air-monitor==1.2.1 # homeassistant.components.nexia nexia==0.9.11 diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index 8106f97ef31..eb723405076 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -1,5 +1,5 @@ """Tests for the Nettigo Air Monitor integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.nam.const import DOMAIN @@ -52,9 +52,11 @@ async def init_integration(hass, co2_sensor=True) -> MockConfigEntry: # Remove conc_co2_ppm value nam_data["sensordatavalues"].pop(6) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=nam_data, + update_response = Mock(json=AsyncMock(return_value=nam_data)) + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index b0ecb20da11..3dbbe416831 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -2,21 +2,22 @@ import asyncio from unittest.mock import patch -from nettigo_air_monitor import ApiError, CannotGetMac +from nettigo_air_monitor import ApiError, AuthFailed, CannotGetMac import pytest from homeassistant import data_entry_flow from homeassistant.components.nam.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from tests.common import MockConfigEntry -DISCOVERY_INFO = {"host": "10.10.2.3", "name": "NAM-12345"} +DISCOVERY_INFO = {"host": "10.10.2.3"} VALID_CONFIG = {"host": "10.10.2.3"} +VALID_AUTH = {"username": "fake_username", "password": "fake_password"} -async def test_form_create_entry(hass): - """Test that the user step works.""" +async def test_form_create_entry_without_auth(hass): + """Test that the user step without auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -24,13 +25,12 @@ async def test_form_create_entry(hass): assert result["step_id"] == SOURCE_USER assert result["errors"] == {} - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( "homeassistant.components.nam.async_setup_entry", return_value=True ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_CONFIG, @@ -43,10 +43,153 @@ async def test_form_create_entry(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_create_entry_with_auth(hass): + """Test that the user step with auth works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( + "homeassistant.components.nam.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "10.10.2.3" + assert result["data"]["host"] == "10.10.2.3" + assert result["data"]["username"] == "fake_username" + assert result["data"]["password"] == "fake_password" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_successful(hass): + """Test starting a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_AUTH, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_unsuccessful(hass): + """Test starting a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=ApiError("API Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_AUTH, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_unsuccessful" + + @pytest.mark.parametrize( "error", [ - (ApiError("Invalid response from device 10.10.2.3: 404"), "cannot_connect"), + (ApiError("API Error"), "cannot_connect"), + (AuthFailed("Auth Error"), "invalid_auth"), + (asyncio.TimeoutError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_form_with_auth_errors(hass, error): + """Test we handle errors when auth is required.""" + exc, base_error = error + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + + assert result["errors"] == {"base": base_error} + + +@pytest.mark.parametrize( + "error", + [ + (ApiError("API Error"), "cannot_connect"), (asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown"), ], @@ -55,7 +198,7 @@ async def test_form_errors(hass, error): """Test we handle errors.""" exc, base_error = error with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=exc, ): @@ -70,11 +213,10 @@ async def test_form_errors(hass, error): async def test_form_abort(hass): """Test we handle abort after error.""" - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=CannotGetMac("Cannot get MAC address from device"), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -85,6 +227,34 @@ async def test_form_abort(hass): assert result["reason"] == "device_unsupported" +async def test_form_with_auth_abort(hass): + """Test we handle abort after error.""" + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=CannotGetMac("Cannot get MAC address from device"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "device_unsupported" + + async def test_form_already_configured(hass): """Test that errors are shown when duplicates are added.""" entry = MockConfigEntry( @@ -96,7 +266,7 @@ async def test_form_already_configured(hass): DOMAIN, context={"source": SOURCE_USER} ) - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -114,7 +284,7 @@ async def test_form_already_configured(hass): async def test_zeroconf(hass): """Test we get the form.""" - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -131,7 +301,7 @@ async def test_zeroconf(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {} - assert context["title_placeholders"]["name"] == "NAM-12345" + assert context["title_placeholders"]["host"] == "10.10.2.3" assert context["confirm_only"] is True with patch( @@ -150,6 +320,48 @@ async def test_zeroconf(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_with_auth(hass): + """Test that the zeroconf step with auth works.""" + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": SOURCE_ZEROCONF}, + ) + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + assert result["errors"] == {} + assert context["title_placeholders"]["host"] == "10.10.2.3" + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( + "homeassistant.components.nam.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "10.10.2.3" + assert result["data"]["host"] == "10.10.2.3" + assert result["data"]["username"] == "fake_username" + assert result["data"]["password"] == "fake_password" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_host_already_configured(hass): """Test that errors are shown when host is already configured.""" entry = MockConfigEntry( @@ -170,7 +382,7 @@ async def test_zeroconf_host_already_configured(hass): @pytest.mark.parametrize( "error", [ - (ApiError("Invalid response from device 10.10.2.3: 404"), "cannot_connect"), + (ApiError("API Error"), "cannot_connect"), (CannotGetMac("Cannot get MAC address from device"), "device_unsupported"), ], ) @@ -178,10 +390,9 @@ async def test_zeroconf_errors(hass, error): """Test we handle errors.""" exc, reason = error with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=exc, ): - result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 97392cbaff8..3223a394f68 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -1,7 +1,7 @@ """Test init of Nettigo Air Monitor integration.""" from unittest.mock import patch -from nettigo_air_monitor import ApiError +from nettigo_air_monitor import ApiError, AuthFailed from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.nam.const import DOMAIN @@ -33,7 +33,7 @@ async def test_config_not_ready(hass): ) with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=ApiError("API Error"), ): entry.add_to_hass(hass) @@ -41,6 +41,24 @@ async def test_config_not_ready(hass): assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_auth_failed(hass): + """Test for setup failure if the auth fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Authorization has failed"), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_unload_entry(hass): """Test successful unload of entry.""" entry = await init_integration(hass) diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 68c0044e590..aa05930f727 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -1,6 +1,6 @@ """Test sensor of Nettigo Air Monitor integration.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from nettigo_air_monitor import ApiError @@ -373,9 +373,10 @@ async def test_incompleta_data_after_device_restart(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS future = utcnow() + timedelta(minutes=6) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=INCOMPLETE_NAM_DATA, + update_response = Mock(json=AsyncMock(return_value=INCOMPLETE_NAM_DATA)) + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -395,8 +396,8 @@ async def test_availability(hass): assert state.state == "7.6" future = utcnow() + timedelta(minutes=6) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", side_effect=ApiError("API Error"), ): async_fire_time_changed(hass, future) @@ -407,9 +408,10 @@ async def test_availability(hass): assert state.state == STATE_UNAVAILABLE future = utcnow() + timedelta(minutes=12) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=nam_data, + update_response = Mock(json=AsyncMock(return_value=nam_data)) + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -426,9 +428,10 @@ async def test_manual_update_entity(hass): await async_setup_component(hass, "homeassistant", {}) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=nam_data, + update_response = Mock(json=AsyncMock(return_value=nam_data)) + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ) as mock_get_data: await hass.services.async_call( "homeassistant",