mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 06:37:52 +00:00
Add OneDrive as backup provider (#135121)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
parent
3d7e3590d4
commit
5695582387
@ -359,6 +359,7 @@ homeassistant.components.number.*
|
|||||||
homeassistant.components.nut.*
|
homeassistant.components.nut.*
|
||||||
homeassistant.components.onboarding.*
|
homeassistant.components.onboarding.*
|
||||||
homeassistant.components.oncue.*
|
homeassistant.components.oncue.*
|
||||||
|
homeassistant.components.onedrive.*
|
||||||
homeassistant.components.onewire.*
|
homeassistant.components.onewire.*
|
||||||
homeassistant.components.onkyo.*
|
homeassistant.components.onkyo.*
|
||||||
homeassistant.components.open_meteo.*
|
homeassistant.components.open_meteo.*
|
||||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1071,6 +1071,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/oncue/ @bdraco @peterager
|
/tests/components/oncue/ @bdraco @peterager
|
||||||
/homeassistant/components/ondilo_ico/ @JeromeHXP
|
/homeassistant/components/ondilo_ico/ @JeromeHXP
|
||||||
/tests/components/ondilo_ico/ @JeromeHXP
|
/tests/components/ondilo_ico/ @JeromeHXP
|
||||||
|
/homeassistant/components/onedrive/ @zweckj
|
||||||
|
/tests/components/onedrive/ @zweckj
|
||||||
/homeassistant/components/onewire/ @garbled1 @epenet
|
/homeassistant/components/onewire/ @garbled1 @epenet
|
||||||
/tests/components/onewire/ @garbled1 @epenet
|
/tests/components/onewire/ @garbled1 @epenet
|
||||||
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
"microsoft_face",
|
"microsoft_face",
|
||||||
"microsoft",
|
"microsoft",
|
||||||
"msteams",
|
"msteams",
|
||||||
|
"onedrive",
|
||||||
"xbox"
|
"xbox"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
167
homeassistant/components/onedrive/__init__.py
Normal file
167
homeassistant/components/onedrive/__init__.py
Normal file
@ -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
|
53
homeassistant/components/onedrive/api.py
Normal file
53
homeassistant/components/onedrive/api.py
Normal file
@ -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])
|
14
homeassistant/components/onedrive/application_credentials.py
Normal file
14
homeassistant/components/onedrive/application_credentials.py
Normal file
@ -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,
|
||||||
|
)
|
290
homeassistant/components/onedrive/backup.py
Normal file
290
homeassistant/components/onedrive/backup.py
Normal file
@ -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)
|
112
homeassistant/components/onedrive/config_flow.py
Normal file
112
homeassistant/components/onedrive/config_flow.py
Normal file
@ -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()
|
24
homeassistant/components/onedrive/const.py
Normal file
24
homeassistant/components/onedrive/const.py
Normal file
@ -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"
|
||||||
|
)
|
13
homeassistant/components/onedrive/manifest.json
Normal file
13
homeassistant/components/onedrive/manifest.json
Normal file
@ -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"]
|
||||||
|
}
|
139
homeassistant/components/onedrive/quality_scale.yaml
Normal file
139
homeassistant/components/onedrive/quality_scale.yaml
Normal file
@ -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
|
53
homeassistant/components/onedrive/strings.json
Normal file
53
homeassistant/components/onedrive/strings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [
|
|||||||
"neato",
|
"neato",
|
||||||
"nest",
|
"nest",
|
||||||
"netatmo",
|
"netatmo",
|
||||||
|
"onedrive",
|
||||||
"point",
|
"point",
|
||||||
"senz",
|
"senz",
|
||||||
"spotify",
|
"spotify",
|
||||||
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -434,6 +434,7 @@ FLOWS = {
|
|||||||
"omnilogic",
|
"omnilogic",
|
||||||
"oncue",
|
"oncue",
|
||||||
"ondilo_ico",
|
"ondilo_ico",
|
||||||
|
"onedrive",
|
||||||
"onewire",
|
"onewire",
|
||||||
"onkyo",
|
"onkyo",
|
||||||
"onvif",
|
"onvif",
|
||||||
|
@ -3802,6 +3802,12 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"name": "Microsoft Teams"
|
"name": "Microsoft Teams"
|
||||||
},
|
},
|
||||||
|
"onedrive": {
|
||||||
|
"integration_type": "service",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"name": "OneDrive"
|
||||||
|
},
|
||||||
"xbox": {
|
"xbox": {
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
10
mypy.ini
generated
10
mypy.ini
generated
@ -3346,6 +3346,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.onewire.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@ -1434,6 +1434,9 @@ motioneye-client==0.3.14
|
|||||||
# homeassistant.components.bang_olufsen
|
# homeassistant.components.bang_olufsen
|
||||||
mozart-api==4.1.1.116.4
|
mozart-api==4.1.1.116.4
|
||||||
|
|
||||||
|
# homeassistant.components.onedrive
|
||||||
|
msgraph-sdk==1.16.0
|
||||||
|
|
||||||
# homeassistant.components.mullvad
|
# homeassistant.components.mullvad
|
||||||
mullvad-api==1.0.0
|
mullvad-api==1.0.0
|
||||||
|
|
||||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@ -1206,6 +1206,9 @@ motioneye-client==0.3.14
|
|||||||
# homeassistant.components.bang_olufsen
|
# homeassistant.components.bang_olufsen
|
||||||
mozart-api==4.1.1.116.4
|
mozart-api==4.1.1.116.4
|
||||||
|
|
||||||
|
# homeassistant.components.onedrive
|
||||||
|
msgraph-sdk==1.16.0
|
||||||
|
|
||||||
# homeassistant.components.mullvad
|
# homeassistant.components.mullvad
|
||||||
mullvad-api==1.0.0
|
mullvad-api==1.0.0
|
||||||
|
|
||||||
|
14
tests/components/onedrive/__init__.py
Normal file
14
tests/components/onedrive/__init__.py
Normal file
@ -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()
|
178
tests/components/onedrive/conftest.py
Normal file
178
tests/components/onedrive/conftest.py
Normal file
@ -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
|
19
tests/components/onedrive/const.py
Normal file
19
tests/components/onedrive/const.py
Normal file
@ -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,
|
||||||
|
}
|
363
tests/components/onedrive/test_backup.py
Normal file
363
tests/components/onedrive/test_backup.py
Normal file
@ -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
|
197
tests/components/onedrive/test_config_flow.py
Normal file
197
tests/components/onedrive/test_config_flow.py
Normal file
@ -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"
|
112
tests/components/onedrive/test_init.py
Normal file
112
tests/components/onedrive/test_init.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user