From 3ba985f771e43bdade405de2dabc8a774a260b9f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Mar 2026 20:40:04 +0200 Subject: [PATCH] Pull out Dropbox integration (#166986) --- .strict-typing | 1 - CODEOWNERS | 2 - homeassistant/components/dropbox/__init__.py | 64 -- .../dropbox/application_credentials.py | 38 -- homeassistant/components/dropbox/auth.py | 44 -- homeassistant/components/dropbox/backup.py | 230 ------- .../components/dropbox/config_flow.py | 60 -- homeassistant/components/dropbox/const.py | 19 - .../components/dropbox/manifest.json | 13 - .../components/dropbox/quality_scale.yaml | 112 ---- homeassistant/components/dropbox/strings.json | 35 -- .../generated/application_credentials.py | 1 - homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - mypy.ini | 10 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/dropbox/__init__.py | 1 - tests/components/dropbox/conftest.py | 114 ---- tests/components/dropbox/test_backup.py | 577 ------------------ tests/components/dropbox/test_config_flow.py | 210 ------- tests/components/dropbox/test_init.py | 100 --- 22 files changed, 1644 deletions(-) delete mode 100644 homeassistant/components/dropbox/__init__.py delete mode 100644 homeassistant/components/dropbox/application_credentials.py delete mode 100644 homeassistant/components/dropbox/auth.py delete mode 100644 homeassistant/components/dropbox/backup.py delete mode 100644 homeassistant/components/dropbox/config_flow.py delete mode 100644 homeassistant/components/dropbox/const.py delete mode 100644 homeassistant/components/dropbox/manifest.json delete mode 100644 homeassistant/components/dropbox/quality_scale.yaml delete mode 100644 homeassistant/components/dropbox/strings.json delete mode 100644 tests/components/dropbox/__init__.py delete mode 100644 tests/components/dropbox/conftest.py delete mode 100644 tests/components/dropbox/test_backup.py delete mode 100644 tests/components/dropbox/test_config_flow.py delete mode 100644 tests/components/dropbox/test_init.py diff --git a/.strict-typing b/.strict-typing index 5e154925661..14a2a7ed98c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -174,7 +174,6 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* -homeassistant.components.dropbox.* homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* diff --git a/CODEOWNERS b/CODEOWNERS index a7fac84580c..32705e6c684 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,8 +401,6 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer -/homeassistant/components/dropbox/ @bdr99 -/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 diff --git a/homeassistant/components/dropbox/__init__.py b/homeassistant/components/dropbox/__init__.py deleted file mode 100644 index 4be8074a5cd..00000000000 --- a/homeassistant/components/dropbox/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -"""The Dropbox integration.""" - -from __future__ import annotations - -from python_dropbox_api import ( - DropboxAPIClient, - DropboxAuthException, - DropboxUnknownException, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, - OAuth2Session, - async_get_config_entry_implementation, -) - -from .auth import DropboxConfigEntryAuth -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN - -type DropboxConfigEntry = ConfigEntry[DropboxAPIClient] - - -async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: - """Set up Dropbox from a config entry.""" - try: - oauth2_implementation = await async_get_config_entry_implementation(hass, entry) - except ImplementationUnavailableError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="oauth2_implementation_unavailable", - ) from err - oauth2_session = OAuth2Session(hass, entry, oauth2_implementation) - - auth = DropboxConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), oauth2_session - ) - - client = DropboxAPIClient(auth) - - try: - await client.get_account_info() - except DropboxAuthException as err: - raise ConfigEntryAuthFailed from err - except (DropboxUnknownException, TimeoutError) as err: - raise ConfigEntryNotReady from err - - entry.runtime_data = client - - def async_notify_backup_listeners() -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: - """Unload a config entry.""" - return True diff --git a/homeassistant/components/dropbox/application_credentials.py b/homeassistant/components/dropbox/application_credentials.py deleted file mode 100644 index 3babe856a28..00000000000 --- a/homeassistant/components/dropbox/application_credentials.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Application credentials platform for the Dropbox integration.""" - -from homeassistant.components.application_credentials import ClientCredential -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - LocalOAuth2ImplementationWithPkce, -) - -from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN - - -async def async_get_auth_implementation( - hass: HomeAssistant, auth_domain: str, credential: ClientCredential -) -> AbstractOAuth2Implementation: - """Return custom auth implementation.""" - return DropboxOAuth2Implementation( - hass, - auth_domain, - credential.client_id, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - credential.client_secret, - ) - - -class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce): - """Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters.""" - - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - data: dict = { - "token_access_type": "offline", - "scope": " ".join(OAUTH2_SCOPES), - } - data.update(super().extra_authorize_data) - return data diff --git a/homeassistant/components/dropbox/auth.py b/homeassistant/components/dropbox/auth.py deleted file mode 100644 index da6d72f6748..00000000000 --- a/homeassistant/components/dropbox/auth.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Authentication for Dropbox.""" - -from typing import cast - -from aiohttp import ClientSession -from python_dropbox_api import Auth - -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - - -class DropboxConfigEntryAuth(Auth): - """Provide Dropbox authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize DropboxConfigEntryAuth.""" - super().__init__(websession) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return cast(str, self._oauth_session.token["access_token"]) - - -class DropboxConfigFlowAuth(Auth): - """Provide authentication tied to a fixed token for the config flow.""" - - def __init__( - self, - websession: ClientSession, - token: str, - ) -> None: - """Initialize DropboxConfigFlowAuth.""" - super().__init__(websession) - self._token = token - - async def async_get_access_token(self) -> str: - """Return the fixed access token.""" - return self._token diff --git a/homeassistant/components/dropbox/backup.py b/homeassistant/components/dropbox/backup.py deleted file mode 100644 index bc7af3d5cbc..00000000000 --- a/homeassistant/components/dropbox/backup.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Backup platform for the Dropbox integration.""" - -from collections.abc import AsyncIterator, Callable, Coroutine -from functools import wraps -import json -import logging -from typing import Any, Concatenate - -from python_dropbox_api import ( - DropboxAPIClient, - DropboxAuthException, - DropboxFileOrFolderNotFoundException, - DropboxUnknownException, -) - -from homeassistant.components.backup import ( - AgentBackup, - BackupAgent, - BackupAgentError, - BackupNotFound, - suggested_filename, -) -from homeassistant.core import HomeAssistant, callback - -from . import DropboxConfigEntry -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: - """Return the suggested filenames for the backup and metadata.""" - base_name = suggested_filename(backup).rsplit(".", 1)[0] - return f"{base_name}.tar", f"{base_name}.metadata.json" - - -async def _async_string_iterator(content: str) -> AsyncIterator[bytes]: - """Yield a string as a single bytes chunk.""" - yield content.encode() - - -def handle_backup_errors[_R, **P]( - func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]: - """Handle backup errors.""" - - @wraps(func) - async def wrapper( - self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs - ) -> _R: - try: - return await func(self, *args, **kwargs) - except DropboxFileOrFolderNotFoundException as err: - raise BackupNotFound( - f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" - ) from err - except DropboxAuthException as err: - self._entry.async_start_reauth(self._hass) - raise BackupAgentError("Authentication error") from err - except DropboxUnknownException as err: - _LOGGER.error( - "Error during %s: %s", - func.__name__, - err, - ) - _LOGGER.debug("Full error: %s", err, exc_info=True) - raise BackupAgentError( - f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" - ) from err - - return wrapper - - -async def async_get_backup_agents( - hass: HomeAssistant, - **kwargs: Any, -) -> list[BackupAgent]: - """Return a list of backup agents.""" - entries = hass.config_entries.async_loaded_entries(DOMAIN) - return [DropboxBackupAgent(hass, entry) for entry in entries] - - -@callback -def async_register_backup_agents_listener( - hass: HomeAssistant, - *, - listener: Callable[[], None], - **kwargs: Any, -) -> Callable[[], None]: - """Register a listener to be called when agents are added or removed. - - :return: A function to unregister the listener. - """ - hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) - - @callback - def remove_listener() -> None: - """Remove the listener.""" - hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) - if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: - del hass.data[DATA_BACKUP_AGENT_LISTENERS] - - return remove_listener - - -class DropboxBackupAgent(BackupAgent): - """Backup agent for the Dropbox integration.""" - - domain = DOMAIN - - def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None: - """Initialize the backup agent.""" - super().__init__() - self._hass = hass - self._entry = entry - self.name = entry.title - assert entry.unique_id - self.unique_id = entry.unique_id - self._api: DropboxAPIClient = entry.runtime_data - - async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]: - """Get backups and their corresponding file names.""" - files = await self._api.list_folder("") - - tar_files = {f.name for f in files if f.name.endswith(".tar")} - metadata_files = [f for f in files if f.name.endswith(".metadata.json")] - - backups: list[tuple[AgentBackup, str]] = [] - for metadata_file in metadata_files: - tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar" - if tar_name not in tar_files: - _LOGGER.warning( - "Found metadata file '%s' without matching backup file", - metadata_file.name, - ) - continue - - metadata_stream = self._api.download_file(f"/{metadata_file.name}") - raw = b"".join([chunk async for chunk in metadata_stream]) - try: - data = json.loads(raw) - backup = AgentBackup.from_dict(data) - except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err: - _LOGGER.warning( - "Skipping invalid metadata file '%s': %s", - metadata_file.name, - err, - ) - continue - backups.append((backup, tar_name)) - - return backups - - @handle_backup_errors - async def async_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - backup: AgentBackup, - **kwargs: Any, - ) -> None: - """Upload a backup.""" - backup_filename, metadata_filename = _suggested_filenames(backup) - backup_path = f"/{backup_filename}" - metadata_path = f"/{metadata_filename}" - - file_stream = await open_stream() - await self._api.upload_file(backup_path, file_stream) - - metadata_stream = _async_string_iterator(json.dumps(backup.as_dict())) - - try: - await self._api.upload_file(metadata_path, metadata_stream) - except ( - DropboxAuthException, - DropboxUnknownException, - ): - await self._api.delete_file(backup_path) - raise - - @handle_backup_errors - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return [backup for backup, _ in await self._async_get_backups()] - - @handle_backup_errors - async def async_download_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - backups = await self._async_get_backups() - for backup, filename in backups: - if backup.backup_id == backup_id: - return self._api.download_file(f"/{filename}") - - raise BackupNotFound(f"Backup {backup_id} not found") - - @handle_backup_errors - async def async_get_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AgentBackup: - """Return a backup.""" - backups = await self._async_get_backups() - - for backup, _ in backups: - if backup.backup_id == backup_id: - return backup - - raise BackupNotFound(f"Backup {backup_id} not found") - - @handle_backup_errors - async def async_delete_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> None: - """Delete a backup file.""" - backups = await self._async_get_backups() - for backup, tar_filename in backups: - if backup.backup_id == backup_id: - metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json" - await self._api.delete_file(f"/{tar_filename}") - await self._api.delete_file(f"/{metadata_filename}") - return - - raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/dropbox/config_flow.py b/homeassistant/components/dropbox/config_flow.py deleted file mode 100644 index 045f858bd59..00000000000 --- a/homeassistant/components/dropbox/config_flow.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Config flow for Dropbox.""" - -from collections.abc import Mapping -import logging -from typing import Any - -from python_dropbox_api import DropboxAPIClient - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler - -from .auth import DropboxConfigFlowAuth -from .const import DOMAIN - - -class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): - """Config flow to handle Dropbox OAuth2 authentication.""" - - DOMAIN = DOMAIN - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: - """Create an entry for the flow, or update existing entry.""" - access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] - - auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token) - - client = DropboxAPIClient(auth) - account_info = await client.get_account_info() - - await self.async_set_unique_id(account_info.account_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=account_info.email, data=data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[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") - return await self.async_step_user() diff --git a/homeassistant/components/dropbox/const.py b/homeassistant/components/dropbox/const.py deleted file mode 100644 index 042f5b5c7bf..00000000000 --- a/homeassistant/components/dropbox/const.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Constants for the Dropbox integration.""" - -from collections.abc import Callable - -from homeassistant.util.hass_dict import HassKey - -DOMAIN = "dropbox" - -OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize" -OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token" -OAUTH2_SCOPES = [ - "account_info.read", - "files.content.read", - "files.content.write", -] - -DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( - f"{DOMAIN}.backup_agent_listeners" -) diff --git a/homeassistant/components/dropbox/manifest.json b/homeassistant/components/dropbox/manifest.json deleted file mode 100644 index 01254682b79..00000000000 --- a/homeassistant/components/dropbox/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "dropbox", - "name": "Dropbox", - "after_dependencies": ["backup"], - "codeowners": ["@bdr99"], - "config_flow": true, - "dependencies": ["application_credentials"], - "documentation": "https://www.home-assistant.io/integrations/dropbox", - "integration_type": "service", - "iot_class": "cloud_polling", - "quality_scale": "bronze", - "requirements": ["python-dropbox-api==0.1.3"] -} diff --git a/homeassistant/components/dropbox/quality_scale.yaml b/homeassistant/components/dropbox/quality_scale.yaml deleted file mode 100644 index 3f46b70b7a5..00000000000 --- a/homeassistant/components/dropbox/quality_scale.yaml +++ /dev/null @@ -1,112 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: Integration does not register any actions. - appropriate-polling: - status: exempt - comment: Integration does not poll. - brands: done - common-modules: - status: exempt - comment: Integration does not have any entities or coordinators. - config-flow-test-coverage: done - config-flow: done - dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register any actions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: Integration does not have any entities. - entity-unique-id: - status: exempt - comment: Integration does not have any entities. - has-entity-name: - status: exempt - comment: Integration does not have any entities. - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done - - # Silver - action-exceptions: - status: exempt - comment: Integration does not register any actions. - config-entry-unloading: done - docs-configuration-parameters: - status: exempt - comment: Integration does not have any configuration parameters. - docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: Integration does not have any entities. - integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: exempt - comment: Integration does not make any entity updates. - reauthentication-flow: done - test-coverage: done - - # Gold - devices: - status: exempt - comment: Integration does not have any entities. - diagnostics: - status: exempt - comment: Integration does not have any data to diagnose. - discovery-update-info: - status: exempt - comment: Integration is a service. - discovery: - status: exempt - comment: Integration is a service. - docs-data-update: - status: exempt - comment: Integration does not update any data. - docs-examples: - status: exempt - comment: Integration only provides backup functionality. - docs-known-limitations: todo - docs-supported-devices: - status: exempt - comment: Integration does not support any devices. - docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: done - dynamic-devices: - status: exempt - comment: Integration does not use any devices. - entity-category: - status: exempt - comment: Integration does not have any entities. - entity-device-class: - status: exempt - comment: Integration does not have any entities. - entity-disabled-by-default: - status: exempt - comment: Integration does not have any entities. - entity-translations: - status: exempt - comment: Integration does not have any entities. - exception-translations: todo - icon-translations: - status: exempt - comment: Integration does not have any entities. - reconfiguration-flow: todo - repair-issues: - status: exempt - comment: Integration does not have any repairs. - stale-devices: - status: exempt - comment: Integration does not have any devices. - - # Platinum - async-dependency: done - inject-websession: done - strict-typing: done diff --git a/homeassistant/components/dropbox/strings.json b/homeassistant/components/dropbox/strings.json deleted file mode 100644 index 4904f997e31..00000000000 --- a/homeassistant/components/dropbox/strings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "config": { - "abort": { - "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%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "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%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "wrong_account": "Wrong account: Please authenticate with the correct account." - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - }, - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - }, - "reauth_confirm": { - "description": "The Dropbox integration needs to re-authenticate your account.", - "title": "[%key:common::config_flow::title::reauth%]" - } - } - }, - "exceptions": { - "oauth2_implementation_unavailable": { - "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" - } - } -} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e916..51435aac0bb 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,7 +6,6 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", - "dropbox", "ekeybionyx", "electric_kiwi", "fitbit", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bb6901c6460..9d223490e6b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -160,7 +160,6 @@ FLOWS = { "downloader", "dremel_3d_printer", "drop_connect", - "dropbox", "droplet", "dsmr", "dsmr_reader", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e43bb5959e6..d5036074b7e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1479,12 +1479,6 @@ "config_flow": true, "iot_class": "local_push" }, - "dropbox": { - "name": "Dropbox", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, "droplet": { "name": "Droplet", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 5b59dbdc476..987b3c7f4ac 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1496,16 +1496,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.dropbox.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.droplet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0bbeb5e2827..ab5875cbbe5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2568,9 +2568,6 @@ python-clementine-remote==1.0.1 # homeassistant.components.digital_ocean python-digitalocean==1.13.2 -# homeassistant.components.dropbox -python-dropbox-api==0.1.3 - # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b9bcea7f9a..b127fd90f0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2188,9 +2188,6 @@ python-awair==0.2.5 # homeassistant.components.bsblan python-bsblan==5.1.3 -# homeassistant.components.dropbox -python-dropbox-api==0.1.3 - # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/tests/components/dropbox/__init__.py b/tests/components/dropbox/__init__.py deleted file mode 100644 index 505d840280e..00000000000 --- a/tests/components/dropbox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Dropbox integration.""" diff --git a/tests/components/dropbox/conftest.py b/tests/components/dropbox/conftest.py deleted file mode 100644 index a5c324c2be5..00000000000 --- a/tests/components/dropbox/conftest.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Shared fixtures for Dropbox integration tests.""" - -from __future__ import annotations - -from collections.abc import Generator -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.components.dropbox.const import DOMAIN, OAUTH2_SCOPES -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -ACCOUNT_ID = "dbid:1234567890abcdef" -ACCOUNT_EMAIL = "user@example.com" -CONFIG_ENTRY_TITLE = "Dropbox test account" -TEST_AGENT_ID = f"{DOMAIN}.{ACCOUNT_ID}" - - -@pytest.fixture(autouse=True) -async def setup_credentials(hass: HomeAssistant) -> None: - """Set up application credentials for Dropbox.""" - - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -@pytest.fixture -def account_info() -> SimpleNamespace: - """Return mocked Dropbox account information.""" - - return SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL) - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return a default Dropbox config entry.""" - - return MockConfigEntry( - domain=DOMAIN, - unique_id=ACCOUNT_ID, - title=CONFIG_ENTRY_TITLE, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_at": 9_999_999_999, - "scope": " ".join(OAUTH2_SCOPES), - }, - }, - ) - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - - with patch( - "homeassistant.components.dropbox.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_dropbox_client(account_info: SimpleNamespace) -> Generator[MagicMock]: - """Patch DropboxAPIClient to exercise auth while mocking API calls.""" - - client = MagicMock() - client.list_folder = AsyncMock(return_value=[]) - client.download_file = MagicMock() - client.upload_file = AsyncMock() - client.delete_file = AsyncMock() - - captured_auth = None - - def capture_auth(auth): - nonlocal captured_auth - captured_auth = auth - return client - - async def get_account_info_with_auth(): - await captured_auth.async_get_access_token() - return client.get_account_info.return_value - - client.get_account_info = AsyncMock( - side_effect=get_account_info_with_auth, - return_value=account_info, - ) - - with ( - patch( - "homeassistant.components.dropbox.config_flow.DropboxAPIClient", - side_effect=capture_auth, - ), - patch( - "homeassistant.components.dropbox.DropboxAPIClient", - side_effect=capture_auth, - ), - ): - yield client diff --git a/tests/components/dropbox/test_backup.py b/tests/components/dropbox/test_backup.py deleted file mode 100644 index 804a37ef3ee..00000000000 --- a/tests/components/dropbox/test_backup.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Test the Dropbox backup platform.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from io import StringIO -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from python_dropbox_api import DropboxAuthException - -from homeassistant.components.backup import ( - DOMAIN as BACKUP_DOMAIN, - AddonInfo, - AgentBackup, - suggested_filename, -) -from homeassistant.components.dropbox.backup import ( - DropboxFileOrFolderNotFoundException, - DropboxUnknownException, - async_register_backup_agents_listener, -) -from homeassistant.components.dropbox.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import mock_stream -from tests.typing import ClientSessionGenerator, WebSocketGenerator - -TEST_AGENT_BACKUP = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="dropbox-backup", - database_included=True, - date="2025-01-01T00:00:00.000Z", - extra_metadata={"with_automatic_settings": False}, - folders=[], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Dropbox backup", - protected=False, - size=2048, -) - -TEST_AGENT_BACKUP_RESULT = { - "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agents": {TEST_AGENT_ID: {"protected": False, "size": 2048}}, - "backup_id": TEST_AGENT_BACKUP.backup_id, - "database_included": True, - "date": TEST_AGENT_BACKUP.date, - "extra_metadata": {"with_automatic_settings": False}, - "failed_addons": [], - "failed_agent_ids": [], - "failed_folders": [], - "folders": [], - "homeassistant_included": True, - "homeassistant_version": TEST_AGENT_BACKUP.homeassistant_version, - "name": TEST_AGENT_BACKUP.name, - "with_automatic_settings": None, -} - - -def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: - """Return the suggested filenames for the backup and metadata.""" - base_name = suggested_filename(backup).rsplit(".", 1)[0] - return f"{base_name}.tar", f"{base_name}.metadata.json" - - -async def _mock_metadata_stream(backup: AgentBackup) -> AsyncIterator[bytes]: - """Create a mock metadata download stream.""" - yield json.dumps(backup.as_dict()).encode() - - -def _setup_list_folder_with_backup( - mock_dropbox_client: Mock, - backup: AgentBackup, -) -> None: - """Set up mock to return a backup in list_folder and download_file.""" - tar_name, metadata_name = _suggested_filenames(backup) - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - ) - mock_dropbox_client.download_file = Mock(return_value=_mock_metadata_stream(backup)) - - -@pytest.fixture(autouse=True) -async def setup_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client, -) -> None: - """Set up the Dropbox and Backup integrations for testing.""" - - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - mock_dropbox_client.reset_mock() - - -async def test_agents_info( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_config_entry: MockConfigEntry, -) -> None: - """Test listing available backup agents.""" - - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "backup/agents/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agents": [ - {"agent_id": "backup.local", "name": "local"}, - {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, - ] - } - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - await client.send_json_auto_id({"type": "backup/agents/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agents": [{"agent_id": "backup.local", "name": "local"}] - } - - -async def test_agents_list_backups( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test listing backups via the Dropbox agent.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] - mock_dropbox_client.list_folder.assert_awaited() - - -async def test_agents_list_backups_metadata_without_tar( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that orphaned metadata files are skipped with a warning.""" - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[SimpleNamespace(name="orphan.metadata.json")] - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [] - assert "without matching backup file" in caplog.text - - -async def test_agents_list_backups_invalid_metadata( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that invalid metadata files are skipped with a warning.""" - - async def _invalid_stream() -> AsyncIterator[bytes]: - yield b"not valid json" - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name="backup.tar"), - SimpleNamespace(name="backup.metadata.json"), - ] - ) - mock_dropbox_client.download_file = Mock(return_value=_invalid_stream()) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [] - assert "Skipping invalid metadata file" in caplog.text - - -async def test_agents_list_backups_fail( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test handling list backups failures.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backups"] == [] - assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" - } - - -async def test_agents_list_backups_reauth( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauthentication is triggered on auth error.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxAuthException("auth failed") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backups"] == [] - assert response["result"]["agent_errors"] == {TEST_AGENT_ID: "Authentication error"} - - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow["step_id"] == "reauth_confirm" - assert flow["handler"] == DOMAIN - assert flow["context"]["source"] == SOURCE_REAUTH - assert flow["context"]["entry_id"] == mock_config_entry.entry_id - - -@pytest.mark.parametrize( - "backup_id", - [TEST_AGENT_BACKUP.backup_id, "other-backup"], - ids=["found", "not_found"], -) -async def test_agents_get_backup( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - backup_id: str, -) -> None: - """Test retrieving a backup's metadata.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - if backup_id == TEST_AGENT_BACKUP.backup_id: - assert response["result"]["backup"] == TEST_AGENT_BACKUP_RESULT - else: - assert response["result"]["backup"] is None - - -async def test_agents_download( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test downloading a backup file.""" - - tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - ) - - def download_side_effect(path: str) -> AsyncIterator[bytes]: - if path == f"/{tar_name}": - return mock_stream(b"backup data") - return _mock_metadata_stream(TEST_AGENT_BACKUP) - - mock_dropbox_client.download_file = Mock(side_effect=download_side_effect) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 200 - assert await resp.content.read() == b"backup data" - - -async def test_agents_download_fail( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test handling download failures.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 500 - body = await resp.content.read() - assert b"Failed to get backup" in body - - -async def test_agents_download_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when backup disappears between get and download.""" - - tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) - files = [ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - - # First list_folder call (async_get_backup) returns the backup; - # second call (async_download_backup) returns empty, simulating deletion. - mock_dropbox_client.list_folder = AsyncMock(side_effect=[files, []]) - mock_dropbox_client.download_file = Mock( - return_value=_mock_metadata_stream(TEST_AGENT_BACKUP) - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - assert await resp.content.read() == b"" - - -async def test_agents_download_file_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when Dropbox file is not found returns 404.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxFileOrFolderNotFoundException("not found") - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - - -async def test_agents_download_metadata_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when metadata lookup fails.""" - - mock_dropbox_client.list_folder = AsyncMock(return_value=[]) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - assert await resp.content.read() == b"" - - -async def test_agents_upload( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_dropbox_client: Mock, -) -> None: - """Test uploading a backup to Dropbox.""" - - mock_dropbox_client.upload_file = AsyncMock(return_value=None) - - client = await hass_client() - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch("pathlib.Path.open") as mocked_open, - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - resp = await client.post( - f"/api/backup/upload?agent_id={TEST_AGENT_ID}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert f"Uploading backup {TEST_AGENT_BACKUP.backup_id} to agents" in caplog.text - assert mock_dropbox_client.upload_file.await_count == 2 - - -async def test_agents_upload_fail( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_dropbox_client: Mock, -) -> None: - """Test that backup tar is cleaned up when metadata upload fails.""" - - call_count = 0 - - async def upload_side_effect(path: str, stream: AsyncIterator[bytes]) -> None: - nonlocal call_count - call_count += 1 - async for _ in stream: - pass - if call_count == 2: - raise DropboxUnknownException("metadata upload failed") - - mock_dropbox_client.upload_file = AsyncMock(side_effect=upload_side_effect) - mock_dropbox_client.delete_file = AsyncMock() - - client = await hass_client() - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch("pathlib.Path.open") as mocked_open, - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - resp = await client.post( - f"/api/backup/upload?agent_id={TEST_AGENT_ID}", - data={"file": StringIO("test")}, - ) - await hass.async_block_till_done() - - assert resp.status == 201 - assert "Failed to upload backup" in caplog.text - mock_dropbox_client.delete_file.assert_awaited_once() - - -async def test_agents_delete( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test deleting a backup.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - mock_dropbox_client.delete_file = AsyncMock(return_value=None) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - assert mock_dropbox_client.delete_file.await_count == 2 - - -async def test_agents_delete_fail( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test error handling when delete fails.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} - } - - -async def test_agents_delete_not_found( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test deleting a backup that does not exist.""" - - mock_dropbox_client.list_folder = AsyncMock(return_value=[]) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - - -async def test_remove_backup_agents_listener( - hass: HomeAssistant, -) -> None: - """Test removing a backup agent listener.""" - listener = Mock() - remove = async_register_backup_agents_listener(hass, listener=listener) - - assert DATA_BACKUP_AGENT_LISTENERS in hass.data - assert listener in hass.data[DATA_BACKUP_AGENT_LISTENERS] - - # Remove all other listeners to test the cleanup path - hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] - - remove() - - assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/dropbox/test_config_flow.py b/tests/components/dropbox/test_config_flow.py deleted file mode 100644 index 9be36ecf0f4..00000000000 --- a/tests/components/dropbox/test_config_flow.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Test the Dropbox config flow.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from yarl import URL - -from homeassistant import config_entries -from homeassistant.components.dropbox.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_SCOPES, - OAUTH2_TOKEN, -) -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow - -from .conftest import ACCOUNT_EMAIL, ACCOUNT_ID, CLIENT_ID - -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_dropbox_client, - mock_setup_entry: AsyncMock, -) -> None: - """Test creating a new config entry through the OAuth 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", - }, - ) - - result_url = URL(result["url"]) - assert f"{result_url.origin()}{result_url.path}" == OAUTH2_AUTHORIZE - assert result_url.query["response_type"] == "code" - assert result_url.query["client_id"] == CLIENT_ID - assert ( - result_url.query["redirect_uri"] == "https://example.com/auth/external/callback" - ) - assert result_url.query["state"] == state - assert result_url.query["scope"] == " ".join(OAUTH2_SCOPES) - assert result_url.query["token_access_type"] == "offline" - assert result_url.query["code_challenge"] - assert result_url.query["code_challenge_method"] == "S256" - - 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": "mock-access-token", - "token_type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == ACCOUNT_EMAIL - assert result["data"]["token"]["access_token"] == "mock-access-token" - assert result["result"].unique_id == ACCOUNT_ID - assert len(mock_setup_entry.mock_calls) == 1 - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_already_configured( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_config_entry, - mock_dropbox_client, -) -> None: - """Test aborting when the account is already configured.""" - - 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", - }, - ) - - 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": "mock-access-token", - "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"] == "already_configured" - - -@pytest.mark.usefixtures("current_request_with_host") -@pytest.mark.parametrize( - ( - "new_account_info", - "expected_reason", - "expected_setup_calls", - "expected_access_token", - ), - [ - ( - SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL), - "reauth_successful", - 1, - "updated-access-token", - ), - ( - SimpleNamespace(account_id="dbid:different", email="other@example.com"), - "wrong_account", - 0, - "mock-access-token", - ), - ], - ids=["success", "wrong_account"], -) -async def test_reauth_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_config_entry, - mock_dropbox_client, - mock_setup_entry: AsyncMock, - new_account_info: SimpleNamespace, - expected_reason: str, - expected_setup_calls: int, - expected_access_token: str, -) -> None: - """Test reauthentication flow outcomes.""" - - mock_config_entry.add_to_hass(hass) - - mock_dropbox_client.get_account_info.return_value = new_account_info - - result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - 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": "updated-access-token", - "token_type": "Bearer", - "expires_in": 120, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_reason - assert mock_setup_entry.await_count == expected_setup_calls - - assert mock_config_entry.data["token"]["access_token"] == expected_access_token diff --git a/tests/components/dropbox/test_init.py b/tests/components/dropbox/test_init.py deleted file mode 100644 index 8d468f18727..00000000000 --- a/tests/components/dropbox/test_init.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test the Dropbox integration setup.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from python_dropbox_api import DropboxAuthException, DropboxUnknownException - -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, -) - -from tests.common import MockConfigEntry - - -@pytest.mark.usefixtures("mock_dropbox_client") -async def test_setup_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test successful setup of a 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 - - -async def test_setup_entry_auth_failed( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client: AsyncMock, -) -> None: - """Test setup failure when authentication fails.""" - mock_dropbox_client.get_account_info.side_effect = DropboxAuthException( - "Invalid token" - ) - 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.SETUP_ERROR - - -@pytest.mark.parametrize( - "side_effect", - [DropboxUnknownException("Unknown error"), TimeoutError("Connection timed out")], - ids=["unknown_exception", "timeout_error"], -) -async def test_setup_entry_not_ready( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client: AsyncMock, - side_effect: Exception, -) -> None: - """Test setup retry when the service is temporarily unavailable.""" - mock_dropbox_client.get_account_info.side_effect = side_effect - 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.SETUP_RETRY - - -async def test_setup_entry_implementation_unavailable( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setup retry when OAuth implementation is unavailable.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.dropbox.async_get_config_entry_implementation", - side_effect=ImplementationUnavailableError, - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("mock_dropbox_client") -async def test_unload_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test unloading a 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 - - 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