diff --git a/.strict-typing b/.strict-typing index 62da6c5ca92..811e5d54c81 100644 --- a/.strict-typing +++ b/.strict-typing @@ -359,6 +359,7 @@ homeassistant.components.number.* homeassistant.components.nut.* homeassistant.components.onboarding.* homeassistant.components.oncue.* +homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* diff --git a/CODEOWNERS b/CODEOWNERS index faded2af138..68a33f34f9a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1071,6 +1071,8 @@ build.json @home-assistant/supervisor /tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP +/homeassistant/components/onedrive/ @zweckj +/tests/components/onedrive/ @zweckj /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 4d9eb5f95f3..0e00c4a7bc3 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -11,6 +11,7 @@ "microsoft_face", "microsoft", "msteams", + "onedrive", "xbox" ] } diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py new file mode 100644 index 00000000000..7419ca6e20c --- /dev/null +++ b/homeassistant/components/onedrive/__init__.py @@ -0,0 +1,167 @@ +"""The OneDrive integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from msgraph import GraphRequestAdapter, GraphServiceClient +from msgraph.generated.drives.item.items.items_request_builder import ( + ItemsRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.folder import Folder + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .api import OneDriveConfigEntryAccessTokenProvider +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + items: ItemsRequestBuilder + backup_folder_id: str + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Set up OneDrive from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=create_async_httpx_client(hass, follow_redirects=True), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + assert entry.unique_id + drive_item = graph_client.drives.by_drive_id(entry.unique_id) + + # get approot, will be created automatically if it does not exist + try: + approot = await drive_item.special.by_drive_item_id("approot").get() + except APIError as err: + if err.response_status_code == 403: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) from err + + if approot is None or not approot.id: + _LOGGER.debug("Failed to get approot, was None") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) + + instance_id = await async_get_instance_id(hass) + backup_folder_id = await _async_create_folder_if_not_exists( + items=drive_item.items, + base_folder_id=approot.id, + folder=f"backups_{instance_id[:8]}", + ) + + entry.runtime_data = OneDriveRuntimeData( + items=drive_item.items, + backup_folder_id=backup_folder_id, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Unload a OneDrive config entry.""" + _async_notify_backup_listeners_soon(hass) + return True + + +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) + + +async def _async_create_folder_if_not_exists( + items: ItemsRequestBuilder, + base_folder_id: str, + folder: str, +) -> str: + """Check if a folder exists and create it if it does not exist.""" + folder_item: DriveItem | None = None + + try: + folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() + except APIError as err: + if err.response_status_code != 404: + _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err + # is 404 not found, create folder + _LOGGER.debug("Creating folder %s", folder) + request_body = DriveItem( + name=folder, + folder=Folder(), + additional_data={ + "@microsoft_graph_conflict_behavior": "fail", + }, + ) + try: + folder_item = await items.by_drive_item_id(base_folder_id).children.post( + request_body + ) + except APIError as create_err: + _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_create_folder", + translation_placeholders={"folder": folder}, + ) from create_err + _LOGGER.debug("Created folder %s", folder) + else: + _LOGGER.debug("Found folder %s", folder) + if folder_item is None or not folder_item.id: + _LOGGER.debug("Failed to get folder %s, was None", folder) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) + return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py new file mode 100644 index 00000000000..934a4f74ec9 --- /dev/null +++ b/homeassistant/components/onedrive/api.py @@ -0,0 +1,53 @@ +"""API for OneDrive bound to Home Assistant OAuth.""" + +from typing import Any, cast + +from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + + +class OneDriveAccessTokenProvider(AccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self) -> None: + """Initialize OneDrive auth.""" + super().__init__() + # currently allowing all hosts + self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) + + def get_allowed_hosts_validator(self) -> AllowedHostsValidator: + """Retrieve the allowed hosts validator.""" + return self._allowed_hosts_validator + + +class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, token: str) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._token = token + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + return self._token + + +class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._oauth_session = oauth_session + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/application_credentials.py b/homeassistant/components/onedrive/application_credentials.py new file mode 100644 index 00000000000..b38aa9313d0 --- /dev/null +++ b/homeassistant/components/onedrive/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for the OneDrive 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, + ) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py new file mode 100644 index 00000000000..a5a5c019797 --- /dev/null +++ b/homeassistant/components/onedrive/backup.py @@ -0,0 +1,290 @@ +"""Support for OneDrive backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import html +import json +import logging +from typing import Any, Concatenate, cast + +from httpx import Response +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import AnonymousAuthenticationProvider +from kiota_abstractions.headers_collection import HeadersCollection +from kiota_abstractions.method import Method +from kiota_abstractions.native_response_handler import NativeResponseHandler +from kiota_abstractions.request_information import RequestInformation +from kiota_http.middleware.options import ResponseHandlerOption +from msgraph import GraphRequestAdapter +from msgraph.generated.drives.item.items.item.content.content_request_builder import ( + ContentRequestBuilder, +) +from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( + CreateUploadSessionPostRequestBody, +) +from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( + DriveItemItemRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_uploadable_properties import ( + DriveItemUploadableProperties, +) +from msgraph_core.models import LargeFileUploadSession + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.httpx_client import get_async_client + +from . import OneDriveConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[OneDriveConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [OneDriveBackupAgent(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.""" + 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 + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors with a specific translation key.""" + + @wraps(func) + async def wrapper( + self: OneDriveBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except APIError as err: + if err.response_status_code == 403: + self._entry.async_start_reauth(self._hass) + _LOGGER.error( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.response_status_code, + err.message, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError("Backup operation failed") from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +class OneDriveBackupAgent(BackupAgent): + """OneDrive backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: + """Initialize the OneDrive backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._items = entry.runtime_data.items + self._folder_id = entry.runtime_data.backup_folder_id + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + + @handle_backup_errors + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + # this forces the query to return a raw httpx response, but breaks typing + request_config = ( + ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( + options=[ResponseHandlerOption(NativeResponseHandler())], + ) + ) + response = cast( + Response, + await self._get_backup_file_item(backup_id).content.get( + request_configuration=request_config + ), + ) + + return response.aiter_bytes(chunk_size=1024) + + @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.""" + + # upload file in chunks to support large files + upload_session_request_body = CreateUploadSessionPostRequestBody( + item=DriveItemUploadableProperties( + additional_data={ + "@microsoft.graph.conflictBehavior": "fail", + }, + ) + ) + upload_session = await self._get_backup_file_item( + backup.backup_id + ).create_upload_session.post(upload_session_request_body) + + if upload_session is None or upload_session.upload_url is None: + raise BackupAgentError( + translation_domain=DOMAIN, translation_key="backup_no_upload_session" + ) + + await self._upload_file( + upload_session.upload_url, await open_stream(), backup.size + ) + + # store metadata in description + backup_dict = backup.as_dict() + backup_dict["metadata_version"] = 1 # version of the backup metadata + description = json.dumps(backup_dict) + _LOGGER.debug("Creating metadata: %s", description) + + await self._get_backup_file_item(backup.backup_id).patch( + DriveItem(description=description) + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + await self._get_backup_file_item(backup_id).delete() + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if "homeassistant_version" in description: + backups.append(self._backup_from_description(description)) + return backups + + @handle_backup_errors + async def async_get_backup( + self, backup_id: str, **kwargs: Any + ) -> AgentBackup | None: + """Return a backup.""" + try: + drive_item = await self._get_backup_file_item(backup_id).get() + except APIError as err: + if err.response_status_code == 404: + return None + raise + if ( + drive_item is not None + and (description := drive_item.description) is not None + ): + return self._backup_from_description(description) + return None + + def _backup_from_description(self, description: str) -> AgentBackup: + """Create a backup object from a description.""" + description = html.unescape( + description + ) # OneDrive encodes the description on save automatically + return AgentBackup.from_dict(json.loads(description)) + + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + + async def _upload_file( + self, upload_url: str, stream: AsyncIterator[bytes], total_size: int + ) -> None: + """Use custom large file upload; SDK does not support stream.""" + + adapter = GraphRequestAdapter( + auth_provider=AnonymousAuthenticationProvider(), + client=get_async_client(self._hass), + ) + + async def async_upload( + start: int, end: int, chunk_data: bytes + ) -> LargeFileUploadSession: + info = RequestInformation() + info.url = upload_url + info.http_method = Method.PUT + info.headers = HeadersCollection() + info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") + info.headers.try_add("Content-Length", str(len(chunk_data))) + info.headers.try_add("Content-Type", "application/octet-stream") + _LOGGER.debug(info.headers.get_all()) + info.set_stream_content(chunk_data) + result = await adapter.send_async(info, LargeFileUploadSession, {}) + _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) + return result + + start = 0 + buffer: list[bytes] = [] + buffer_size = 0 + + async for chunk in stream: + buffer.append(chunk) + buffer_size += len(chunk) + if buffer_size >= UPLOAD_CHUNK_SIZE: + chunk_data = b"".join(buffer) + uploaded_chunks = 0 + while ( + buffer_size > UPLOAD_CHUNK_SIZE + ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 + slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + start += UPLOAD_CHUNK_SIZE + uploaded_chunks += 1 + buffer_size -= UPLOAD_CHUNK_SIZE + buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] + + # upload the remaining bytes + if buffer: + _LOGGER.debug("Last chunk") + chunk_data = b"".join(buffer) + await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py new file mode 100644 index 00000000000..83f6dd6e2ee --- /dev/null +++ b/homeassistant/components/onedrive/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for OneDrive.""" + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from kiota_abstractions.method import Method +from kiota_abstractions.request_information import RequestInformation +from msgraph import GraphRequestAdapter, GraphServiceClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.httpx_client import get_async_client + +from .api import OneDriveConfigFlowAccessTokenProvider +from .const import DOMAIN, OAUTH_SCOPES + + +class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle OneDrive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH_SCOPES)} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle the initial step.""" + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + ) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=get_async_client(self.hass), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + + # need to get adapter from client, as client changes it + request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) + + request_info = RequestInformation( + method=Method.GET, + url_template="{+baseurl}/me/drive/special/approot", + path_parameters={}, + ) + parent_span = request_adapter.start_tracing_span(request_info, "get_approot") + + # get the OneDrive id + # use low level methods, to avoid files.read permissions + # which would be required by drives.me.get() + try: + response = await request_adapter.get_http_response_message( + request_info=request_info, parent_span=parent_span + ) + except APIError: + self.logger.exception("Failed to connect to OneDrive") + return self.async_abort(reason="connection_error") + except Exception: + self.logger.exception("Unknown error") + return self.async_abort(reason="unknown") + + drive = response.json() + + await self.async_set_unique_id(drive["parentReference"]["driveId"]) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_drive", + ) + return self.async_update_reload_and_abort( + entry=reauth_entry, + data=data, + ) + + self._abort_if_unique_id_configured() + + title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + return self.async_create_entry(title=title, 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: + """Confirm reauth dialog.""" + 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/onedrive/const.py b/homeassistant/components/onedrive/const.py new file mode 100644 index 00000000000..f9d49b141e5 --- /dev/null +++ b/homeassistant/components/onedrive/const.py @@ -0,0 +1,24 @@ +"""Constants for the OneDrive integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "onedrive" + +# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support +OAUTH2_AUTHORIZE: Final = ( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" +) +OAUTH2_TOKEN: Final = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +OAUTH_SCOPES: Final = [ + "Files.ReadWrite.AppFolder", + "offline_access", + "openid", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json new file mode 100644 index 00000000000..056e31864a4 --- /dev/null +++ b/homeassistant/components/onedrive/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "onedrive", + "name": "OneDrive", + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/onedrive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["msgraph", "msgraph-core", "kiota"], + "quality_scale": "bronze", + "requirements": ["msgraph-sdk==1.16.0"] +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml new file mode 100644 index 00000000000..f0d58d89c9a --- /dev/null +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -0,0 +1,139 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: + status: exempt + comment: | + No issues known to troubleshoot. + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json new file mode 100644 index 00000000000..9cbdb2bdeae --- /dev/null +++ b/homeassistant/components/onedrive/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The OneDrive integration 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%]", + "connection_error": "Failed to connect to OneDrive.", + "wrong_drive": "New account does not contain previously configured OneDrive.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "failed_to_create_folder": "Failed to create backup folder" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "exceptions": { + "backup_not_found": { + "message": "Backup not found" + }, + "backup_no_content": { + "message": "Backup has no content" + }, + "backup_no_upload_session": { + "message": "Failed to start backup upload" + }, + "authentication_failed": { + "message": "Authentication failed" + }, + "failed_to_get_folder": { + "message": "Failed to get {folder} folder" + }, + "failed_to_create_folder": { + "message": "Failed to create {folder} folder" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6b3028826dc..ef55798b3a0 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "onedrive", "point", "senz", "spotify", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7dea4598790..12dda0f56be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -434,6 +434,7 @@ FLOWS = { "omnilogic", "oncue", "ondilo_ico", + "onedrive", "onewire", "onkyo", "onvif", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6d2e784c583..53a485a1340 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3802,6 +3802,12 @@ "iot_class": "cloud_push", "name": "Microsoft Teams" }, + "onedrive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "OneDrive" + }, "xbox": { "integration_type": "hub", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index 188f1f7bbd7..db1ec0a04e4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3346,6 +3346,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onedrive.*] +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.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e9436475775..128586eb01e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1434,6 +1434,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1752dc7e45..117886a0bc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1206,6 +1206,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/tests/components/onedrive/__init__.py b/tests/components/onedrive/__init__.py new file mode 100644 index 00000000000..0bafe37775b --- /dev/null +++ b/tests/components/onedrive/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the OneDrive integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OneDrive integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py new file mode 100644 index 00000000000..0cca8e9df0b --- /dev/null +++ b/tests/components/onedrive/conftest.py @@ -0,0 +1,178 @@ +"""Fixtures for OneDrive tests.""" + +from collections.abc import AsyncIterator, Generator +from html import escape +from json import dumps +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import Response +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_collection_response import ( + DriveItemCollectionResponse, +) +from msgraph.generated.models.upload_session import UploadSession +from msgraph_core.models import LargeFileUploadSession +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return OAUTH_SCOPES + + +@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(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + unique_id="mock_drive_id", + ) + + +@pytest.fixture +def mock_adapter() -> Generator[MagicMock]: + """Return a mocked GraphAdapter.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", + autospec=True, + ) as mock_adapter, + patch( + "homeassistant.components.onedrive.backup.GraphRequestAdapter", + new=mock_adapter, + ), + ): + adapter = mock_adapter.return_value + adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "mock_drive_id"}, + "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + }, + ) + yield adapter + adapter.send_async.return_value = LargeFileUploadSession( + next_expected_ranges=["2-"] + ) + + +@pytest.fixture(autouse=True) +def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphServiceClient", + autospec=True, + ) as graph_client, + patch( + "homeassistant.components.onedrive.GraphServiceClient", + new=graph_client, + ), + ): + client = graph_client.return_value + + client.request_adapter = mock_adapter + + drives = client.drives.by_drive_id.return_value + drives.special.by_drive_item_id.return_value.get = AsyncMock( + return_value=DriveItem(id="approot") + ) + + drive_items = drives.items.by_drive_item_id.return_value + drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.get = AsyncMock( + return_value=DriveItemCollectionResponse( + value=[ + DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem(), + ] + ) + ) + drive_items.delete = AsyncMock(return_value=None) + drive_items.create_upload_session.post = AsyncMock( + return_value=UploadSession(upload_url="https://test.tld") + ) + drive_items.patch = AsyncMock(return_value=None) + + async def generate_bytes() -> AsyncIterator[bytes]: + """Asynchronous generator that yields bytes.""" + yield b"backup data" + + drive_items.content.get = AsyncMock( + return_value=Response(status_code=200, content=generate_bytes()) + ) + + yield client + + +@pytest.fixture +def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: + """Return a mocked DriveItems.""" + return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value + + +@pytest.fixture +def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: + """Mock the get special folder method.""" + return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onedrive.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock the instance ID.""" + with patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + ): + yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py new file mode 100644 index 00000000000..c187feef30a --- /dev/null +++ b/tests/components/onedrive/const.py @@ -0,0 +1,19 @@ +"""Consts for OneDrive tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, +} diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py new file mode 100644 index 00000000000..a3cfbe95a46 --- /dev/null +++ b/tests/components/onedrive/test_backup.py @@ -0,0 +1,363 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from html import escape +from io import StringIO +from json import dumps +from unittest.mock import Mock, patch + +from kiota_abstractions.api_error import APIError +from msgraph.generated.models.drive_item import DriveItem +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + 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": f"{DOMAIN}.{mock_config_entry.unique_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + 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"] == [ + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + backup_id = BACKUP_METADATA["backup_id"] + 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"] == {} + assert response["result"]["backup"] == { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.create_upload_session.post.assert_called_once() + mock_drive_items.patch.assert_called_once() + assert mock_adapter.send_async.call_count == 2 + assert mock_adapter.method_calls[0].args[0].content == b"tes" + assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { + "bytes 0-2/34519040" + } + assert mock_adapter.method_calls[1].args[0].content == b"t" + assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { + "bytes 3-3/34519040" + } + + +async def test_broken_upload_session( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test broken upload session.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Failed to start backup upload" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_drive_items.content.get.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + APIError(response_status_code=404, message="File not found."), + "Backup operation failed", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + mock_drive_items.delete = AsyncMock(side_effect=side_effect) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.unique_id}": error} + } + + +@pytest.mark.parametrize( + "problem", + [ + AsyncMock(return_value=None), + AsyncMock(side_effect=APIError(response_status_code=404)), + ], +) +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + problem: AsyncMock, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = problem + backup_id = BACKUP_METADATA["backup_id"] + 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"]["backup"] is None + + +async def test_agents_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) + backup_id = BACKUP_METADATA["backup_id"] + 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"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + +async def test_reauth_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we re-authenticate on 403.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + backup_id = BACKUP_METADATA["backup_id"] + 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"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + 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 "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py new file mode 100644 index 00000000000..8be6aadfd0f --- /dev/null +++ b/tests/components/onedrive/test_config_flow.py @@ -0,0 +1,197 @@ +"""Test the OneDrive config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +from httpx import Response +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant import config_entries +from homeassistant.components.onedrive.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "Files.ReadWrite.AppFolder+offline_access+openid" + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + 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", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "unknown"), + (APIError, "connection_error"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_adapter: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors during flow.""" + + mock_adapter.get_http_response_message.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test already configured account.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + 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_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + 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"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test that the reauth flow fails on a different drive id.""" + mock_adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "other_drive_id"}, + }, + ) + + await setup_integration(hass, mock_config_entry) + + 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"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py new file mode 100644 index 00000000000..bc5c22c3ce6 --- /dev/null +++ b/tests/components/onedrive/test_init.py @@ -0,0 +1,112 @@ +"""Test the OneDrive setup.""" + +from unittest.mock import MagicMock + +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + 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 + + +@pytest.mark.parametrize( + ("side_effect", "state"), + [ + (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), + (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_approot_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + side_effect: Exception, + state: ConfigEntryState, +) -> None: + """Test errors during approot retrieval.""" + mock_get_special_folder.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_faulty_approot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_get_special_folder.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get approot folder" in caplog.text + + +async def test_faulty_integration_folder( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_drive_items.get.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_500_error_during_backup_folder_get( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=500) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_error_during_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + mock_drive_items.children.post.side_effect = APIError() + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to create backups_9f86d081 folder" in caplog.text + + +async def test_successful_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, +) -> None: + """Test successful backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED