mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Migrate OneDrive to onedrive_personal_sdk library (#137064)
This commit is contained in:
@ -5,34 +5,33 @@ 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 onedrive_personal_sdk import OneDriveClient
|
||||
from onedrive_personal_sdk.exceptions import (
|
||||
AuthenticationError,
|
||||
HttpRequestException,
|
||||
OneDriveException,
|
||||
)
|
||||
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.aiohttp_client import async_get_clientsession
|
||||
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
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
|
||||
@dataclass
|
||||
class OneDriveRuntimeData:
|
||||
"""Runtime data for the OneDrive integration."""
|
||||
|
||||
items: ItemsRequestBuilder
|
||||
client: OneDriveClient
|
||||
token_provider: OneDriveConfigEntryAccessTokenProvider
|
||||
backup_folder_id: str
|
||||
|
||||
|
||||
@ -47,29 +46,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
|
||||
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),
|
||||
)
|
||||
token_provider = OneDriveConfigEntryAccessTokenProvider(session)
|
||||
|
||||
graph_client = GraphServiceClient(
|
||||
request_adapter=adapter,
|
||||
scopes=OAUTH_SCOPES,
|
||||
)
|
||||
assert entry.unique_id
|
||||
drive_item = graph_client.drives.by_drive_id(entry.unique_id)
|
||||
client = OneDriveClient(token_provider, async_get_clientsession(hass))
|
||||
|
||||
# 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
|
||||
approot = await client.get_approot()
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from err
|
||||
except (HttpRequestException, OneDriveException, TimeoutError) as err:
|
||||
_LOGGER.debug("Failed to get approot", exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
@ -77,24 +65,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
translation_placeholders={"folder": "approot"},
|
||||
) from err
|
||||
|
||||
if approot is None or not approot.id:
|
||||
_LOGGER.debug("Failed to get approot, was None")
|
||||
instance_id = await async_get_instance_id(hass)
|
||||
backup_folder_name = f"backups_{instance_id[:8]}"
|
||||
try:
|
||||
backup_folder = await client.create_folder(
|
||||
parent_id=approot.id, name=backup_folder_name
|
||||
)
|
||||
except (HttpRequestException, OneDriveException, TimeoutError) as err:
|
||||
_LOGGER.debug("Failed to create backup folder", exc_info=True)
|
||||
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]}",
|
||||
)
|
||||
translation_placeholders={"folder": backup_folder_name},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = OneDriveRuntimeData(
|
||||
items=drive_item.items,
|
||||
backup_folder_id=backup_folder_id,
|
||||
client=client,
|
||||
token_provider=token_provider,
|
||||
backup_folder_id=backup_folder.id,
|
||||
)
|
||||
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
@ -116,54 +104,3 @@ def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
@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
|
||||
|
@ -1,28 +1,14 @@
|
||||
"""API for OneDrive bound to Home Assistant OAuth."""
|
||||
|
||||
from typing import Any, cast
|
||||
from typing import cast
|
||||
|
||||
from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator
|
||||
from onedrive_personal_sdk import TokenProvider
|
||||
|
||||
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):
|
||||
class OneDriveConfigFlowAccessTokenProvider(TokenProvider):
|
||||
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
@ -30,14 +16,12 @@ class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider):
|
||||
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."""
|
||||
def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
return self._token
|
||||
|
||||
|
||||
class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider):
|
||||
class OneDriveConfigEntryAccessTokenProvider(TokenProvider):
|
||||
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None:
|
||||
@ -45,9 +29,6 @@ class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider):
|
||||
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()
|
||||
def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN])
|
||||
|
@ -2,37 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate, cast
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from httpx import Response, TimeoutException
|
||||
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 aiohttp import ClientTimeout
|
||||
from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient
|
||||
from onedrive_personal_sdk.exceptions import (
|
||||
AuthenticationError,
|
||||
HashMismatchError,
|
||||
OneDriveException,
|
||||
)
|
||||
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 onedrive_personal_sdk.models.items import File, Folder, ItemUpdate
|
||||
from onedrive_personal_sdk.models.upload import FileInfo
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
@ -41,14 +26,14 @@ from homeassistant.components.backup import (
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from . import OneDriveConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
|
||||
MAX_RETRIES = 5
|
||||
TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
@ -92,18 +77,18 @@ def handle_backup_errors[_R, **P](
|
||||
) -> _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)
|
||||
except AuthenticationError as err:
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
raise BackupAgentError("Authentication error") from err
|
||||
except OneDriveException as err:
|
||||
_LOGGER.error(
|
||||
"Error during backup in %s: Status %s, message %s",
|
||||
"Error during backup in %s:, message %s",
|
||||
func.__name__,
|
||||
err.response_status_code,
|
||||
err.message,
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Full error: %s", err, exc_info=True)
|
||||
raise BackupAgentError("Backup operation failed") from err
|
||||
except TimeoutException as err:
|
||||
except TimeoutError as err:
|
||||
_LOGGER.error(
|
||||
"Error during backup in %s: Timeout",
|
||||
func.__name__,
|
||||
@ -123,7 +108,8 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
self._items = entry.runtime_data.items
|
||||
self._client = entry.runtime_data.client
|
||||
self._token_provider = entry.runtime_data.token_provider
|
||||
self._folder_id = entry.runtime_data.backup_folder_id
|
||||
self.name = entry.title
|
||||
assert entry.unique_id
|
||||
@ -134,24 +120,12 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
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
|
||||
backup = await self._find_item_by_backup_id(backup_id)
|
||||
if backup is None or backup.id is None:
|
||||
item = await self._find_item_by_backup_id(backup_id)
|
||||
if item is None:
|
||||
raise BackupAgentError("Backup not found")
|
||||
|
||||
request_config = (
|
||||
ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration(
|
||||
options=[ResponseHandlerOption(NativeResponseHandler())],
|
||||
)
|
||||
)
|
||||
response = cast(
|
||||
Response,
|
||||
await self._items.by_drive_item_id(backup.id).content.get(
|
||||
request_configuration=request_config
|
||||
),
|
||||
)
|
||||
|
||||
return response.aiter_bytes(chunk_size=1024)
|
||||
stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT)
|
||||
return stream.iter_chunked(1024)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_upload_backup(
|
||||
@ -163,27 +137,20 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
) -> 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",
|
||||
},
|
||||
file = FileInfo(
|
||||
suggested_filename(backup),
|
||||
backup.size,
|
||||
self._folder_id,
|
||||
await open_stream(),
|
||||
)
|
||||
try:
|
||||
item = await LargeFileUploadClient.upload(
|
||||
self._token_provider, file, session=async_get_clientsession(self._hass)
|
||||
)
|
||||
)
|
||||
file_item = self._get_backup_file_item(suggested_filename(backup))
|
||||
upload_session = await file_item.create_upload_session.post(
|
||||
upload_session_request_body
|
||||
)
|
||||
|
||||
if upload_session is None or upload_session.upload_url is None:
|
||||
except HashMismatchError as err:
|
||||
raise BackupAgentError(
|
||||
translation_domain=DOMAIN, translation_key="backup_no_upload_session"
|
||||
)
|
||||
|
||||
await self._upload_file(
|
||||
upload_session.upload_url, await open_stream(), backup.size
|
||||
)
|
||||
"Hash validation failed, backup file might be corrupt"
|
||||
) from err
|
||||
|
||||
# store metadata in description
|
||||
backup_dict = backup.as_dict()
|
||||
@ -191,7 +158,10 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
description = json.dumps(backup_dict)
|
||||
_LOGGER.debug("Creating metadata: %s", description)
|
||||
|
||||
await file_item.patch(DriveItem(description=description))
|
||||
await self._client.update_drive_item(
|
||||
path_or_id=item.id,
|
||||
data=ItemUpdate(description=description),
|
||||
)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
@ -200,35 +170,31 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
backup = await self._find_item_by_backup_id(backup_id)
|
||||
if backup is None or backup.id is None:
|
||||
item = await self._find_item_by_backup_id(backup_id)
|
||||
if item is None:
|
||||
return
|
||||
await self._items.by_drive_item_id(backup.id).delete()
|
||||
await self._client.delete_drive_item(item.id)
|
||||
|
||||
@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
|
||||
return [
|
||||
self._backup_from_description(item.description)
|
||||
for item in await self._client.list_drive_items(self._folder_id)
|
||||
if item.description and "homeassistant_version" in item.description
|
||||
]
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self, backup_id: str, **kwargs: Any
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
backup = await self._find_item_by_backup_id(backup_id)
|
||||
if backup is None:
|
||||
return None
|
||||
|
||||
assert backup.description # already checked in _find_item_by_backup_id
|
||||
return self._backup_from_description(backup.description)
|
||||
item = await self._find_item_by_backup_id(backup_id)
|
||||
return (
|
||||
self._backup_from_description(item.description)
|
||||
if item and item.description
|
||||
else None
|
||||
)
|
||||
|
||||
def _backup_from_description(self, description: str) -> AgentBackup:
|
||||
"""Create a backup object from a description."""
|
||||
@ -237,91 +203,13 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
) # OneDrive encodes the description on save automatically
|
||||
return AgentBackup.from_dict(json.loads(description))
|
||||
|
||||
async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None:
|
||||
"""Find a backup item by its backup ID."""
|
||||
|
||||
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 backup_id in description:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder:
|
||||
return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:")
|
||||
|
||||
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 _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None:
|
||||
"""Find an item by backup ID."""
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in await self._client.list_drive_items(self._folder_id)
|
||||
if item.description and backup_id in item.description
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
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
|
||||
retries = 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
|
||||
try:
|
||||
await async_upload(
|
||||
start,
|
||||
start + UPLOAD_CHUNK_SIZE - 1,
|
||||
chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE],
|
||||
)
|
||||
except APIError as err:
|
||||
if (
|
||||
err.response_status_code and err.response_status_code < 500
|
||||
): # no retry on 4xx errors
|
||||
raise
|
||||
if retries < MAX_RETRIES:
|
||||
await asyncio.sleep(2**retries)
|
||||
retries += 1
|
||||
continue
|
||||
raise
|
||||
except TimeoutException:
|
||||
if retries < MAX_RETRIES:
|
||||
retries += 1
|
||||
continue
|
||||
raise
|
||||
retries = 0
|
||||
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)
|
||||
|
@ -4,16 +4,13 @@ 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 onedrive_personal_sdk.clients.client import OneDriveClient
|
||||
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .api import OneDriveConfigFlowAccessTokenProvider
|
||||
from .const import DOMAIN, OAUTH_SCOPES
|
||||
@ -39,48 +36,24 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
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),
|
||||
token_provider = OneDriveConfigFlowAccessTokenProvider(
|
||||
cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||
)
|
||||
|
||||
graph_client = GraphServiceClient(
|
||||
request_adapter=adapter,
|
||||
scopes=OAUTH_SCOPES,
|
||||
graph_client = OneDriveClient(
|
||||
token_provider, async_get_clientsession(self.hass)
|
||||
)
|
||||
|
||||
# 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:
|
||||
approot = await graph_client.get_approot()
|
||||
except OneDriveException:
|
||||
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: dict = response.json()
|
||||
|
||||
await self.async_set_unique_id(drive["parentReference"]["driveId"])
|
||||
await self.async_set_unique_id(approot.parent_reference.drive_id)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
@ -94,10 +67,11 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
user = drive.get("createdBy", {}).get("user", {}).get("displayName")
|
||||
|
||||
title = f"{user}'s OneDrive" if user else "OneDrive"
|
||||
|
||||
title = (
|
||||
f"{approot.created_by.user.display_name}'s OneDrive"
|
||||
if approot.created_by.user and approot.created_by.user.display_name
|
||||
else "OneDrive"
|
||||
)
|
||||
return self.async_create_entry(title=title, data=data)
|
||||
|
||||
async def async_step_reauth(
|
||||
|
@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/onedrive",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["msgraph", "msgraph-core", "kiota"],
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["msgraph-sdk==1.16.0"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.1"]
|
||||
}
|
||||
|
@ -23,31 +23,18 @@
|
||||
"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"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
requirements_all.txt
generated
6
requirements_all.txt
generated
@ -1434,9 +1434,6 @@ 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
|
||||
|
||||
@ -1558,6 +1555,9 @@ omnilogic==0.4.5
|
||||
# homeassistant.components.ondilo_ico
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.1
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==3.2.5
|
||||
|
||||
|
6
requirements_test_all.txt
generated
6
requirements_test_all.txt
generated
@ -1206,9 +1206,6 @@ 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
|
||||
|
||||
@ -1306,6 +1303,9 @@ omnilogic==0.4.5
|
||||
# homeassistant.components.ondilo_ico
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.1
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==3.2.5
|
||||
|
||||
|
@ -1,18 +1,9 @@
|
||||
"""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 (
|
||||
@ -23,7 +14,13 @@ 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 .const import (
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
MOCK_APPROOT,
|
||||
MOCK_BACKUP_FILE,
|
||||
MOCK_BACKUP_FOLDER,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@ -70,96 +67,41 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
|
||||
)
|
||||
|
||||
|
||||
@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"},
|
||||
"createdBy": {"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]:
|
||||
def mock_onedrive_client() -> Generator[MagicMock]:
|
||||
"""Return a mocked GraphServiceClient."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.onedrive.config_flow.GraphServiceClient",
|
||||
"homeassistant.components.onedrive.config_flow.OneDriveClient",
|
||||
autospec=True,
|
||||
) as graph_client,
|
||||
) as onedrive_client,
|
||||
patch(
|
||||
"homeassistant.components.onedrive.GraphServiceClient",
|
||||
new=graph_client,
|
||||
"homeassistant.components.onedrive.OneDriveClient",
|
||||
new=onedrive_client,
|
||||
),
|
||||
):
|
||||
client = graph_client.return_value
|
||||
client = onedrive_client.return_value
|
||||
client.get_approot.return_value = MOCK_APPROOT
|
||||
client.create_folder.return_value = MOCK_BACKUP_FOLDER
|
||||
client.list_drive_items.return_value = [MOCK_BACKUP_FILE]
|
||||
client.get_drive_item.return_value = MOCK_BACKUP_FILE
|
||||
|
||||
client.request_adapter = mock_adapter
|
||||
class MockStreamReader:
|
||||
async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]:
|
||||
yield b"backup data"
|
||||
|
||||
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(
|
||||
id=BACKUP_METADATA["backup_id"],
|
||||
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())
|
||||
)
|
||||
client.download_drive_item.return_value = MockStreamReader()
|
||||
|
||||
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
|
||||
def mock_large_file_upload_client() -> Generator[AsyncMock]:
|
||||
"""Return a mocked LargeFileUploadClient upload."""
|
||||
with patch(
|
||||
"homeassistant.components.onedrive.backup.LargeFileUploadClient.upload"
|
||||
) as mock_upload:
|
||||
yield mock_upload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -179,10 +121,3 @@ def mock_instance_id() -> Generator[AsyncMock]:
|
||||
return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_asyncio_sleep() -> Generator[AsyncMock]:
|
||||
"""Mock asyncio.sleep."""
|
||||
with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()):
|
||||
yield
|
||||
|
@ -1,5 +1,18 @@
|
||||
"""Consts for OneDrive tests."""
|
||||
|
||||
from html import escape
|
||||
from json import dumps
|
||||
|
||||
from onedrive_personal_sdk.models.items import (
|
||||
AppRoot,
|
||||
Contributor,
|
||||
File,
|
||||
Folder,
|
||||
Hashes,
|
||||
ItemParentReference,
|
||||
User,
|
||||
)
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
|
||||
@ -17,3 +30,48 @@ BACKUP_METADATA = {
|
||||
"protected": False,
|
||||
"size": 34519040,
|
||||
}
|
||||
|
||||
CONTRIBUTOR = Contributor(
|
||||
user=User(
|
||||
display_name="John Doe",
|
||||
id="id",
|
||||
email="john@doe.com",
|
||||
)
|
||||
)
|
||||
|
||||
MOCK_APPROOT = AppRoot(
|
||||
id="id",
|
||||
child_count=0,
|
||||
size=0,
|
||||
name="name",
|
||||
parent_reference=ItemParentReference(
|
||||
drive_id="mock_drive_id", id="id", path="path"
|
||||
),
|
||||
created_by=CONTRIBUTOR,
|
||||
)
|
||||
|
||||
MOCK_BACKUP_FOLDER = Folder(
|
||||
id="id",
|
||||
name="name",
|
||||
size=0,
|
||||
child_count=0,
|
||||
parent_reference=ItemParentReference(
|
||||
drive_id="mock_drive_id", id="id", path="path"
|
||||
),
|
||||
created_by=CONTRIBUTOR,
|
||||
)
|
||||
|
||||
MOCK_BACKUP_FILE = File(
|
||||
id="id",
|
||||
name="23e64aec.tar",
|
||||
size=34519040,
|
||||
parent_reference=ItemParentReference(
|
||||
drive_id="mock_drive_id", id="id", path="path"
|
||||
),
|
||||
hashes=Hashes(
|
||||
quick_xor_hash="hash",
|
||||
),
|
||||
mime_type="application/x-tar",
|
||||
description=escape(dumps(BACKUP_METADATA)),
|
||||
created_by=CONTRIBUTOR,
|
||||
)
|
||||
|
@ -3,15 +3,14 @@
|
||||
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 httpx import TimeoutException
|
||||
from kiota_abstractions.api_error import APIError
|
||||
from msgraph.generated.models.drive_item import DriveItem
|
||||
from msgraph_core.models import LargeFileUploadSession
|
||||
from onedrive_personal_sdk.exceptions import (
|
||||
AuthenticationError,
|
||||
HashMismatchError,
|
||||
OneDriveException,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup
|
||||
@ -102,14 +101,10 @@ async def test_agents_list_backups(
|
||||
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})
|
||||
@ -140,7 +135,7 @@ async def test_agents_get_backup(
|
||||
async def test_agents_delete(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent delete backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
@ -155,37 +150,15 @@ async def test_agents_delete(
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {"agent_errors": {}}
|
||||
mock_drive_items.delete.assert_called_once()
|
||||
|
||||
|
||||
async def test_agents_delete_not_found_does_not_throw(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent delete backup."""
|
||||
mock_drive_items.children.get = AsyncMock(return_value=[])
|
||||
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": {}}
|
||||
assert mock_drive_items.delete.call_count == 0
|
||||
mock_onedrive_client.delete_drive_item.assert_called_once()
|
||||
|
||||
|
||||
async def test_agents_upload(
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_large_file_upload_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_adapter: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent upload backup."""
|
||||
client = await hass_client()
|
||||
@ -200,7 +173,6 @@ async def test_agents_upload(
|
||||
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
|
||||
@ -211,31 +183,22 @@ async def test_agents_upload(
|
||||
|
||||
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"
|
||||
}
|
||||
mock_large_file_upload_client.assert_called_once()
|
||||
mock_onedrive_client.update_drive_item.assert_called_once()
|
||||
|
||||
|
||||
async def test_broken_upload_session(
|
||||
async def test_agents_upload_corrupt_upload(
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_large_file_upload_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test broken upload session."""
|
||||
"""Test hash validation fails."""
|
||||
mock_large_file_upload_client.side_effect = HashMismatchError("test")
|
||||
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",
|
||||
@ -254,152 +217,18 @@ async def test_broken_upload_session(
|
||||
)
|
||||
|
||||
assert resp.status == 201
|
||||
assert "Failed to start backup upload" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[
|
||||
APIError(response_status_code=500),
|
||||
TimeoutException("Timeout"),
|
||||
],
|
||||
)
|
||||
async def test_agents_upload_errors_retried(
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_adapter: MagicMock,
|
||||
side_effect: Exception,
|
||||
) -> None:
|
||||
"""Test agent upload backup."""
|
||||
client = await hass_client()
|
||||
test_backup = AgentBackup.from_dict(BACKUP_METADATA)
|
||||
|
||||
mock_adapter.send_async.side_effect = [
|
||||
side_effect,
|
||||
LargeFileUploadSession(next_expected_ranges=["2-"]),
|
||||
LargeFileUploadSession(next_expected_ranges=["2-"]),
|
||||
]
|
||||
|
||||
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 mock_adapter.send_async.call_count == 3
|
||||
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||
mock_drive_items.patch.assert_called_once()
|
||||
|
||||
|
||||
async def test_agents_upload_4xx_errors_not_retried(
|
||||
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)
|
||||
|
||||
mock_adapter.send_async.side_effect = APIError(response_status_code=404)
|
||||
|
||||
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 mock_adapter.send_async.call_count == 1
|
||||
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||
assert mock_drive_items.patch.call_count == 0
|
||||
assert "Backup operation failed" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error"),
|
||||
[
|
||||
(APIError(response_status_code=500), "Backup operation failed"),
|
||||
(TimeoutException("Timeout"), "Backup operation timed out"),
|
||||
],
|
||||
)
|
||||
async def test_agents_upload_fails_after_max_retries(
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_adapter: MagicMock,
|
||||
side_effect: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test agent upload backup."""
|
||||
client = await hass_client()
|
||||
test_backup = AgentBackup.from_dict(BACKUP_METADATA)
|
||||
|
||||
mock_adapter.send_async.side_effect = side_effect
|
||||
|
||||
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 mock_adapter.send_async.call_count == 6
|
||||
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||
assert mock_drive_items.patch.call_count == 0
|
||||
assert error in caplog.text
|
||||
mock_large_file_upload_client.assert_called_once()
|
||||
assert mock_onedrive_client.update_drive_item.call_count == 0
|
||||
assert "Hash validation failed, backup file might be corrupt" in caplog.text
|
||||
|
||||
|
||||
async def test_agents_download(
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: 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"]
|
||||
|
||||
@ -408,29 +237,30 @@ async def test_agents_download(
|
||||
)
|
||||
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=500),
|
||||
OneDriveException(),
|
||||
"Backup operation failed",
|
||||
),
|
||||
(TimeoutException("Timeout"), "Backup operation timed out"),
|
||||
(TimeoutError(), "Backup operation timed out"),
|
||||
],
|
||||
)
|
||||
async def test_delete_error(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test error during delete."""
|
||||
mock_drive_items.delete = AsyncMock(side_effect=side_effect)
|
||||
mock_onedrive_client.delete_drive_item.side_effect = AsyncMock(
|
||||
side_effect=side_effect
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
@ -448,14 +278,35 @@ async def test_delete_error(
|
||||
}
|
||||
|
||||
|
||||
async def test_agents_delete_not_found_does_not_throw(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_onedrive_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent delete backup."""
|
||||
mock_onedrive_client.list_drive_items.return_value = []
|
||||
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": {}}
|
||||
|
||||
|
||||
async def test_agents_backup_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test backup not found."""
|
||||
|
||||
mock_drive_items.children.get = AsyncMock(return_value=[])
|
||||
mock_onedrive_client.list_drive_items.return_value = []
|
||||
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})
|
||||
@ -468,13 +319,13 @@ async def test_agents_backup_not_found(
|
||||
async def test_reauth_on_403(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test we re-authenticate on 403."""
|
||||
|
||||
mock_drive_items.children.get = AsyncMock(
|
||||
side_effect=APIError(response_status_code=403)
|
||||
mock_onedrive_client.list_drive_items.side_effect = AuthenticationError(
|
||||
403, "Auth failed"
|
||||
)
|
||||
backup_id = BACKUP_METADATA["backup_id"]
|
||||
client = await hass_ws_client(hass)
|
||||
@ -483,7 +334,7 @@ async def test_reauth_on_403(
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["agent_errors"] == {
|
||||
f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed"
|
||||
f"{DOMAIN}.{mock_config_entry.unique_id}": "Authentication error"
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
@ -3,8 +3,7 @@
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from httpx import Response
|
||||
from kiota_abstractions.api_error import APIError
|
||||
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
@ -20,7 +19,7 @@ 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 .const import CLIENT_ID, MOCK_APPROOT
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
@ -89,25 +88,52 @@ async def test_full_flow(
|
||||
assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_full_flow_with_owner_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
) -> None:
|
||||
"""Ensure we get a default title if the drive's owner can't be read."""
|
||||
|
||||
mock_onedrive_client.get_approot.return_value.created_by.user = None
|
||||
|
||||
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"] == "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"),
|
||||
(OneDriveException, "connection_error"),
|
||||
],
|
||||
)
|
||||
async def test_flow_errors(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_adapter: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test errors during flow."""
|
||||
|
||||
mock_adapter.get_http_response_message.side_effect = exception
|
||||
mock_onedrive_client.get_approot.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
@ -172,15 +198,12 @@ async def test_reauth_flow_id_changed(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_adapter: MagicMock,
|
||||
mock_onedrive_client: 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"},
|
||||
},
|
||||
)
|
||||
app_root = MOCK_APPROOT
|
||||
app_root.parent_reference.drive_id = "other_drive_id"
|
||||
mock_onedrive_client.get_approot.return_value = app_root
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kiota_abstractions.api_error import APIError
|
||||
from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@ -31,82 +31,31 @@ async def test_load_unload_config_entry(
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "state"),
|
||||
[
|
||||
(APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR),
|
||||
(APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY),
|
||||
(AuthenticationError(403, "Auth failed"), ConfigEntryState.SETUP_ERROR),
|
||||
(OneDriveException(), ConfigEntryState.SETUP_RETRY),
|
||||
],
|
||||
)
|
||||
async def test_approot_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_get_special_folder: MagicMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
side_effect: Exception,
|
||||
state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test errors during approot retrieval."""
|
||||
mock_get_special_folder.side_effect = side_effect
|
||||
mock_onedrive_client.get_approot.side_effect = side_effect
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is state
|
||||
|
||||
|
||||
async def test_faulty_approot(
|
||||
async def test_get_integration_folder_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_get_special_folder: MagicMock,
|
||||
mock_onedrive_client: 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
|
||||
mock_onedrive_client.create_folder.side_effect = OneDriveException()
|
||||
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
|
||||
|
Reference in New Issue
Block a user