diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 48bedafdd1a..2af0f4e8859 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -2,10 +2,12 @@ from __future__ import annotations +import aiohttp from genie_partner_sdk.client import AladdinConnectClient from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -31,11 +33,27 @@ async def async_setup_entry( session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + client = AladdinConnectClient( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - doors = await client.get_doors() + try: + doors = await client.get_doors() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err entry.runtime_data = { door.unique_id: AladdinConnectCoordinator(hass, entry, client, door) diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py index ea46bf69f4a..481aa06be65 100644 --- a/homeassistant/components/aladdin_connect/api.py +++ b/homeassistant/components/aladdin_connect/api.py @@ -11,6 +11,18 @@ API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1" API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3" +class AsyncConfigFlowAuth(Auth): + """Provide Aladdin Connect Genie authentication for config flow validation.""" + + def __init__(self, websession: ClientSession, access_token: str) -> None: + """Initialize Aladdin Connect Genie auth.""" + super().__init__(websession, API_URL, access_token, API_KEY) + + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self.access_token + + class AsyncConfigEntryAuth(Auth): """Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index dab801d4712..66aa67ffd01 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -4,12 +4,14 @@ from collections.abc import Mapping import logging from typing import Any +from genie_partner_sdk.client import AladdinConnectClient import jwt import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from .api import AsyncConfigFlowAuth from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN @@ -52,11 +54,25 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" - # Extract the user ID from the JWT token's 'sub' field - token = jwt.decode( - data["token"]["access_token"], options={"verify_signature": False} + try: + token = jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + ) + user_id = token["sub"] + except jwt.DecodeError, KeyError: + return self.async_abort(reason="oauth_error") + + client = AladdinConnectClient( + AsyncConfigFlowAuth( + aiohttp_client.async_get_clientsession(self.hass), + data["token"]["access_token"], + ) ) - user_id = token["sub"] + try: + await client.get_doors() + except Exception: # noqa: BLE001 + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(user_id) if self.source == SOURCE_REAUTH: diff --git a/homeassistant/components/aladdin_connect/quality_scale.yaml b/homeassistant/components/aladdin_connect/quality_scale.yaml index 88d454a5532..d857f1dcdc2 100644 --- a/homeassistant/components/aladdin_connect/quality_scale.yaml +++ b/homeassistant/components/aladdin_connect/quality_scale.yaml @@ -7,39 +7,31 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: todo + config-flow-test-coverage: done dependency-transparency: done docs-actions: status: exempt comment: Integration does not register any service actions. docs-high-level-description: done - docs-installation-instructions: - status: todo - comment: Documentation needs to be created. - docs-removal-instructions: - status: todo - comment: Documentation needs to be created. + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: Integration does not subscribe to external events. entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: - status: todo - comment: Config flow does not currently test connection during setup. - test-before-setup: todo + test-before-configure: done + test-before-setup: done unique-config-entry: done # Silver action-exceptions: todo config-entry-unloading: done docs-configuration-parameters: - status: todo - comment: Documentation needs to be created. - docs-installation-parameters: - status: todo - comment: Documentation needs to be created. + status: exempt + comment: Integration does not have an options flow. + docs-installation-parameters: done entity-unavailable: todo integration-owner: done log-when-unavailable: todo @@ -52,29 +44,17 @@ rules: # Gold devices: done diagnostics: todo - discovery: todo - discovery-update-info: todo - docs-data-update: - status: todo - comment: Documentation needs to be created. - docs-examples: - status: todo - comment: Documentation needs to be created. - docs-known-limitations: - status: todo - comment: Documentation needs to be created. - docs-supported-devices: - status: todo - comment: Documentation needs to be created. - docs-supported-functions: - status: todo - comment: Documentation needs to be created. - docs-troubleshooting: - status: todo - comment: Documentation needs to be created. - docs-use-cases: - status: todo - comment: Documentation needs to be created. + discovery: done + discovery-update-info: + status: exempt + comment: Integration connects via the cloud and not locally. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done @@ -86,7 +66,7 @@ rules: repair-issues: todo stale-devices: status: todo - comment: Stale devices can be done dynamically + comment: We can automatically remove removed devices # Platinum async-dependency: todo diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index bac173a5632..d8a12ae5ba7 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -4,6 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", diff --git a/tests/components/aladdin_connect/__init__.py b/tests/components/aladdin_connect/__init__.py index aa5957dc392..4909ebf90ff 100644 --- a/tests/components/aladdin_connect/__init__.py +++ b/tests/components/aladdin_connect/__init__.py @@ -1 +1,12 @@ """Tests for the Aladdin Connect Garage Door integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Aladdin Connect integration for testing.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index 1843ba9db28..16e56f7d928 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -1,5 +1,9 @@ """Fixtures for aladdin_connect tests.""" +from collections.abc import Generator +from time import time +from unittest.mock import AsyncMock, patch + import pytest from homeassistant.components.aladdin_connect import DOMAIN @@ -27,6 +31,42 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +def mock_aladdin_connect_api() -> Generator[AsyncMock]: + """Mock the AladdinConnectClient.""" + mock_door = AsyncMock() + mock_door.device_id = "test_device_id" + mock_door.door_number = 1 + mock_door.name = "Test Door" + mock_door.status = "closed" + mock_door.link_status = "connected" + mock_door.battery_level = 100 + mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" + + with ( + patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_doors.return_value = [mock_door] + yield client + + +@pytest.fixture +def mock_setup_entry() -> AsyncMock: + """Fixture to mock setup entry.""" + with patch( + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True + ): + yield + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Define a mock config entry fixture.""" @@ -41,7 +81,7 @@ def mock_config_entry() -> MockConfigEntry: "access_token": "old-token", "refresh_token": "old-refresh-token", "expires_in": 3600, - "expires_at": 1234567890, + "expires_at": time() + 3600, }, }, source="user", diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index ee555cf2ebb..24a77b42ce5 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Aladdin Connect Garage Door config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest @@ -43,7 +43,12 @@ async def access_token(hass: HomeAssistant) -> str: ) -@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +@pytest.mark.usefixtures( + "current_request_with_host", + "use_cloud", + "mock_setup_entry", + "mock_aladdin_connect_api", +) async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -83,10 +88,7 @@ async def test_full_flow( }, ) - with patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Aladdin Connect" @@ -103,7 +105,12 @@ async def test_full_flow( assert result["result"].unique_id == USER_ID -@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +@pytest.mark.usefixtures( + "current_request_with_host", + "use_cloud", + "mock_setup_entry", + "mock_aladdin_connect_api", +) async def test_full_dhcp_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -156,10 +163,7 @@ async def test_full_dhcp_flow( }, ) - with patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Aladdin Connect" @@ -176,7 +180,9 @@ async def test_full_dhcp_flow( assert result["result"].unique_id == USER_ID -@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +@pytest.mark.usefixtures( + "current_request_with_host", "use_cloud", "mock_aladdin_connect_api" +) async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -218,10 +224,7 @@ async def test_duplicate_entry( }, ) - with patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -249,7 +252,12 @@ async def test_duplicate_dhcp_entry( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +@pytest.mark.usefixtures( + "current_request_with_host", + "use_cloud", + "mock_setup_entry", + "mock_aladdin_connect_api", +) async def test_flow_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -301,10 +309,7 @@ async def test_flow_reauth( }, ) - with patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -312,7 +317,9 @@ async def test_flow_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 -@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +@pytest.mark.usefixtures( + "current_request_with_host", "use_cloud", "mock_aladdin_connect_api" +) async def test_flow_wrong_account_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -412,3 +419,82 @@ async def test_reauthentication_no_cloud( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +async def test_flow_connection_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_aladdin_connect_api: AsyncMock, +) -> None: + """Test config flow aborts when API connection fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + mock_aladdin_connect_api.get_doors.side_effect = Exception("Connection failed") + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +async def test_flow_invalid_token( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test config flow aborts when JWT token is invalid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "not-a-valid-jwt-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth_error" diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index bc147839c2f..421836adbc5 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -1,116 +1,108 @@ """Tests for the Aladdin Connect integration.""" +import http from unittest.mock import AsyncMock, patch -from homeassistant.components.aladdin_connect.const import DOMAIN +from aiohttp import ClientConnectionError, RequestInfo +from aiohttp.client_exceptions import ClientResponseError +import pytest + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import init_integration + from tests.common import MockConfigEntry -async def test_setup_entry(hass: HomeAssistant) -> None: +async def test_setup_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test a successful setup entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": { - "access_token": "test_token", - "refresh_token": "test_refresh_token", - } - }, - unique_id="test_unique_id", - ) - config_entry.add_to_hass(hass) - - mock_door = AsyncMock() - mock_door.device_id = "test_device_id" - mock_door.door_number = 1 - mock_door.name = "Test Door" - mock_door.status = "closed" - mock_door.link_status = "connected" - mock_door.battery_level = 100 - mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" - - mock_client = AsyncMock() - mock_client.get_doors.return_value = [mock_door] - - with ( - patch( - "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.async_get_config_entry_implementation", - return_value=AsyncMock(), - ), - patch( - "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.OAuth2Session", - return_value=AsyncMock(), - ), - patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_client, - ), - patch( - "homeassistant.components.aladdin_connect.api.AsyncConfigEntryAuth", - return_value=AsyncMock(), - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test a successful unload entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": { - "access_token": "test_token", - "refresh_token": "test_refresh_token", - } - }, - unique_id="test_unique_id", - ) - config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED - # Mock door data - mock_door = AsyncMock() - mock_door.device_id = "test_device_id" - mock_door.door_number = 1 - mock_door.name = "Test Door" - mock_door.status = "closed" - mock_door.link_status = "connected" - mock_door.battery_level = 100 - mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" + 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 - # Mock client - mock_client = AsyncMock() - mock_client.get_doors.return_value = [mock_door] - with ( - patch( - "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.async_get_config_entry_implementation", - return_value=AsyncMock(), - ), - patch( - "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.OAuth2Session", - return_value=AsyncMock(), - ), - patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_client, - ), - patch( - "homeassistant.components.aladdin_connect.api.AsyncConfigEntryAuth", - return_value=AsyncMock(), +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + (http.HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR), + (http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY), + ], + ids=["auth_failure", "server_error"], +) +async def test_setup_entry_token_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry fails when token validation fails.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + RequestInfo("", "POST", {}, ""), None, status=status ), ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await init_integration(hass, mock_config_entry) - assert config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is expected_state - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED +async def test_setup_entry_token_connection_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup entry retries when token validation has a connection error.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientConnectionError(), + ): + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + (http.HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR), + (http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY), + ], + ids=["auth_failure", "server_error"], +) +async def test_setup_entry_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry fails when API call fails.""" + mock_aladdin_connect_api.get_doors.side_effect = ClientResponseError( + RequestInfo("", "GET", {}, ""), None, status=status + ) + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is expected_state + + +async def test_setup_entry_api_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, +) -> None: + """Test setup entry retries when API has a connection error.""" + mock_aladdin_connect_api.get_doors.side_effect = ClientConnectionError() + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY