Re-add aladdin_connect integration (#149029)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Jamie Magee
2025-08-27 10:06:53 -07:00
committed by GitHub
parent abb59f2233
commit 8fc334b338
23 changed files with 2911 additions and 101 deletions

2
CODEOWNERS generated
View File

@@ -87,6 +87,8 @@ build.json @home-assistant/supervisor
/tests/components/airzone/ @Noltari
/homeassistant/components/airzone_cloud/ @Noltari
/tests/components/airzone_cloud/ @Noltari
/homeassistant/components/aladdin_connect/ @swcloudgenie
/tests/components/aladdin_connect/ @swcloudgenie
/homeassistant/components/alarm_control_panel/ @home-assistant/core
/tests/components/alarm_control_panel/ @home-assistant/core
/homeassistant/components/alert/ @home-assistant/core @frenck

View File

@@ -2,39 +2,112 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
device_registry as dr,
)
DOMAIN = "aladdin_connect"
from . import api
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
"""Set up Aladdin Connect from a config entry."""
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/aladdin_connect",
},
async def async_setup_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Set up Aladdin Connect Genie from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
client = AladdinConnectClient(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
sdk_doors = await client.get_doors()
# Convert SDK GarageDoor objects to integration GarageDoor objects
doors = [
GarageDoor(
{
"device_id": door.device_id,
"door_number": door.door_number,
"name": door.name,
"status": door.status,
"link_status": door.link_status,
"battery_level": door.battery_level,
}
)
for door in sdk_doors
]
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
for door in doors
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
remove_stale_devices(hass, entry)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> bool:
"""Migrate old config."""
if config_entry.version < CONFIG_FLOW_VERSION:
config_entry.async_start_reauth(hass)
new_data = {**config_entry.data}
hass.config_entries.async_update_entry(
config_entry,
data=new_data,
version=CONFIG_FLOW_VERSION,
minor_version=CONFIG_FLOW_MINOR_VERSION,
)
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
def remove_stale_devices(
hass: HomeAssistant,
config_entry: AladdinConnectConfigEntry,
) -> None:
"""Remove stale devices from device registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = set(config_entry.runtime_data)
for device_entry in device_entries:
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id and device_id not in all_device_ids:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)

View File

@@ -0,0 +1,33 @@
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
from typing import cast
from aiohttp import ClientSession
from genie_partner_sdk.auth import Auth
from homeassistant.helpers import config_entry_oauth2_flow
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(
websession, API_URL, oauth_session.token["access_token"], API_KEY
)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])

View File

@@ -0,0 +1,14 @@
"""application_credentials platform the Aladdin Connect Genie integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)

View File

@@ -1,11 +1,63 @@
"""Config flow for Aladdin Connect integration."""
"""Config flow for Aladdin Connect Genie."""
from homeassistant.config_entries import ConfigFlow
from collections.abc import Mapping
import logging
from typing import Any
from . import DOMAIN
import jwt
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aladdin Connect."""
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
VERSION = 1
DOMAIN = DOMAIN
VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon API auth error or upgrade from v1 to v2."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
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}
)
user_id = token["sub"]
await self.async_set_unique_id(user_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aladdin Connect", data=data)
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)

View File

@@ -0,0 +1,14 @@
"""Constants for the Aladdin Connect Genie integration."""
from typing import Final
from homeassistant.components.cover import CoverEntityFeature
DOMAIN = "aladdin_connect"
CONFIG_FLOW_VERSION = 2
CONFIG_FLOW_MINOR_VERSION = 1
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE

View File

@@ -0,0 +1,44 @@
"""Coordinator for Aladdin Connect integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
SCAN_INTERVAL = timedelta(seconds=15)
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
"""Coordinator for Aladdin Connect integration."""
def __init__(
self,
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
client: AladdinConnectClient,
garage_door: GarageDoor,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
config_entry=entry,
name="Aladdin Connect Coordinator",
update_interval=SCAN_INTERVAL,
)
self.client = client
self.data = garage_door
async def _async_update_data(self) -> GarageDoor:
"""Fetch data from the Aladdin Connect API."""
await self.client.update_door(self.data.device_id, self.data.door_number)
return self.data

View File

@@ -0,0 +1,62 @@
"""Cover Entity for Genie Garage Door."""
from __future__ import annotations
from typing import Any
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SUPPORTED_FEATURES
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cover platform."""
coordinators = entry.runtime_data
async_add_entities(
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
)
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
"""Representation of Aladdin Connect cover."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_supported_features = SUPPORTED_FEATURES
_attr_name = None
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
"""Initialize the Aladdin Connect cover."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.unique_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
await self.client.open_door(self._device_id, self._number)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Issue close command to cover."""
await self.client.close_door(self._device_id, self._number)
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
return self.coordinator.data.status == "closed"
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
return self.coordinator.data.status == "closing"
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
return self.coordinator.data.status == "opening"

View File

@@ -0,0 +1,32 @@
"""Base class for Aladdin Connect entities."""
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
"""Defines a base Aladdin Connect entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
"""Initialize Aladdin Connect entity."""
super().__init__(coordinator)
device = coordinator.data
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
manufacturer="Aladdin Connect",
name=device.name,
)
self._device_id = device.device_id
self._number = device.door_number
@property
def client(self) -> AladdinConnectClient:
"""Return the client for this entity."""
return self.coordinator.client

View File

@@ -1,9 +1,11 @@
{
"domain": "aladdin_connect",
"name": "Aladdin Connect",
"codeowners": [],
"codeowners": ["@swcloudgenie"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
"integration_type": "system",
"iot_class": "cloud_polling",
"requirements": []
"requirements": ["genie-partner-sdk==1.0.10"]
}

View File

@@ -0,0 +1,94 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register any service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: todo
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.
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
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.
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage.
# 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.
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: Stale devices can be done dynamically
# Platinum
async-dependency: todo
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,77 @@
"""Support for Aladdin Connect Genie sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
@dataclass(frozen=True, kw_only=True)
class AladdinConnectSensorEntityDescription(SensorEntityDescription):
"""Sensor entity description for Aladdin Connect."""
value_fn: Callable[[GarageDoor], float | None]
SENSOR_TYPES: tuple[AladdinConnectSensorEntityDescription, ...] = (
AladdinConnectSensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY,
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda garage_door: garage_door.battery_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinators = entry.runtime_data
async_add_entities(
AladdinConnectSensor(coordinator, description)
for coordinator in coordinators.values()
for description in SENSOR_TYPES
)
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
"""A sensor implementation for Aladdin Connect device."""
entity_description: AladdinConnectSensorEntityDescription
def __init__(
self,
coordinator: AladdinConnectCoordinator,
entity_description: AladdinConnectSensorEntityDescription,
) -> None:
"""Initialize the Aladdin Connect sensor."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -1,8 +1,30 @@
{
"issues": {
"integration_removed": {
"title": "The Aladdin Connect integration has been removed",
"description": "The Aladdin Connect integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Aladdin Connect integration entries]({entries})."
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Aladdin Connect needs to re-authenticate your account"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View File

@@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest
"""
APPLICATION_CREDENTIALS = [
"aladdin_connect",
"august",
"electric_kiwi",
"fitbit",

View File

@@ -47,6 +47,7 @@ FLOWS = {
"airvisual_pro",
"airzone",
"airzone_cloud",
"aladdin_connect",
"alarmdecoder",
"alexa_devices",
"altruist",

3
requirements_all.txt generated
View File

@@ -1000,6 +1000,9 @@ gassist-text==0.0.14
# homeassistant.components.google
gcal-sync==8.0.0
# homeassistant.components.aladdin_connect
genie-partner-sdk==1.0.10
# homeassistant.components.geniushub
geniushub-client==0.7.1

View File

@@ -870,6 +870,9 @@ gassist-text==0.0.14
# homeassistant.components.google
gcal-sync==8.0.0
# homeassistant.components.aladdin_connect
genie-partner-sdk==1.0.10
# homeassistant.components.geniushub
geniushub-client==0.7.1

View File

@@ -139,7 +139,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"airvisual_pro",
"airzone",
"airzone_cloud",
"aladdin_connect",
"alarmdecoder",
"alert",
"alexa",

View File

@@ -0,0 +1,48 @@
"""Fixtures for aladdin_connect tests."""
import pytest
from homeassistant.components.aladdin_connect import DOMAIN
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .const import CLIENT_ID, CLIENT_SECRET, USER_ID
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Define a mock config entry fixture."""
return MockConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Aladdin Connect",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "old-token",
"refresh_token": "old-refresh-token",
"expires_in": 3600,
"expires_at": 1234567890,
},
},
source="user",
unique_id=USER_ID,
)

View File

@@ -0,0 +1,5 @@
"""Constants for aladdin_connect tests."""
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
USER_ID = "test_user_123"

View File

@@ -0,0 +1,275 @@
"""Test the Aladdin Connect Garage Door config flow."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.aladdin_connect.const import (
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .const import CLIENT_ID, USER_ID
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@pytest.fixture
async def access_token(hass: HomeAssistant) -> str:
"""Return a valid access token with sub field for unique ID."""
return config_entry_oauth2_flow._encode_jwt(
hass,
{
"sub": USER_ID,
"aud": [],
"iat": 1234567890,
"exp": 1234567890 + 3600,
},
)
@pytest.mark.usefixtures("current_request_with_host")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token,
) -> None:
"""Check full flow."""
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",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Aladdin Connect"
assert result["data"] == {
"auth_implementation": DOMAIN,
"token": {
"access_token": access_token,
"refresh_token": "mock-refresh-token",
"expires_in": 60,
"expires_at": result["data"]["token"]["expires_at"],
"type": "Bearer",
},
}
assert result["result"].unique_id == USER_ID
@pytest.mark.usefixtures("current_request_with_host")
async def test_duplicate_entry(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token,
mock_config_entry: MockConfigEntry,
) -> None:
"""Check full flow."""
mock_config_entry.add_to_hass(hass)
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",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("current_request_with_host")
async def test_flow_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await mock_config_entry.start_reauth_flow(hass)
# Should show reauth confirm form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
# Confirm reauth
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
# Should now go to user step (OAuth)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
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": "new-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
# Verify the entry was updated, not a new one created
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@pytest.mark.usefixtures("current_request_with_host")
async def test_flow_wrong_account_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with wrong account."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await mock_config_entry.start_reauth_flow(hass)
# Should show reauth confirm form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
# Create access token for a different user
different_user_token = config_entry_oauth2_flow._encode_jwt(
hass,
{
"sub": "different_user_456",
"aud": [],
"iat": 1234567890,
"exp": 1234567890 + 3600,
},
)
# Start reauth flow
result = await mock_config_entry.start_reauth_flow(hass)
# Confirm reauth
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
# Complete OAuth with different 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": "wrong-user-refresh-token",
"access_token": different_user_token,
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Should abort with wrong account
assert result["type"] == "abort"
assert result["reason"] == "wrong_account"

View File

@@ -1,79 +1,114 @@
"""Tests for the Aladdin Connect integration."""
from homeassistant.components.aladdin_connect import DOMAIN
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigEntryDisabler,
ConfigEntryState,
)
from unittest.mock import AsyncMock, patch
from homeassistant.components.aladdin_connect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from tests.common import MockConfigEntry
async def test_aladdin_connect_repair_issue(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test the Aladdin Connect configuration entry loading/unloading handles the repair."""
config_entry_1 = MockConfigEntry(
title="Example 1",
async def test_setup_entry(hass: HomeAssistant) -> 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_1.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry_1.entry_id)
await hass.async_block_till_done()
assert config_entry_1.state is ConfigEntryState.LOADED
config_entry.add_to_hass(hass)
# Add a second one
config_entry_2 = MockConfigEntry(
title="Example 2",
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_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
async def test_unload_entry(hass: HomeAssistant) -> 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_2.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry_2.entry_id)
config_entry.add_to_hass(hass)
# 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 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(),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry_2.state is ConfigEntryState.LOADED
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
# Add an ignored entry
config_entry_3 = MockConfigEntry(
source=SOURCE_IGNORE,
domain=DOMAIN,
)
config_entry_3.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry_3.entry_id)
await hass.async_block_till_done()
assert config_entry_3.state is ConfigEntryState.NOT_LOADED
# Add a disabled entry
config_entry_4 = MockConfigEntry(
disabled_by=ConfigEntryDisabler.USER,
domain=DOMAIN,
)
config_entry_4.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry_4.entry_id)
await hass.async_block_till_done()
assert config_entry_4.state is ConfigEntryState.NOT_LOADED
# Remove the first one
await hass.config_entries.async_remove(config_entry_1.entry_id)
await hass.async_block_till_done()
assert config_entry_1.state is ConfigEntryState.NOT_LOADED
assert config_entry_2.state is ConfigEntryState.LOADED
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
# Remove the second one
await hass.config_entries.async_remove(config_entry_2.entry_id)
await hass.async_block_till_done()
assert config_entry_1.state is ConfigEntryState.NOT_LOADED
assert config_entry_2.state is ConfigEntryState.NOT_LOADED
assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None
# Check the ignored and disabled entries are removed
assert not hass.config_entries.async_entries(DOMAIN)
assert config_entry.state is ConfigEntryState.NOT_LOADED

1919
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff