mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 14:57:09 +00:00
Migrate OneDrive to onedrive_personal_sdk library (#137064)
This commit is contained in:
parent
05ca80f4ba
commit
628e1ffb84
@ -5,34 +5,33 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from kiota_abstractions.api_error import APIError
|
from onedrive_personal_sdk import OneDriveClient
|
||||||
from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider
|
from onedrive_personal_sdk.exceptions import (
|
||||||
from msgraph import GraphRequestAdapter, GraphServiceClient
|
AuthenticationError,
|
||||||
from msgraph.generated.drives.item.items.items_request_builder import (
|
HttpRequestException,
|
||||||
ItemsRequestBuilder,
|
OneDriveException,
|
||||||
)
|
)
|
||||||
from msgraph.generated.models.drive_item import DriveItem
|
|
||||||
from msgraph.generated.models.folder import Folder
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||||
OAuth2Session,
|
OAuth2Session,
|
||||||
async_get_config_entry_implementation,
|
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 homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||||
|
|
||||||
from .api import OneDriveConfigEntryAccessTokenProvider
|
from .api import OneDriveConfigEntryAccessTokenProvider
|
||||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES
|
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OneDriveRuntimeData:
|
class OneDriveRuntimeData:
|
||||||
"""Runtime data for the OneDrive integration."""
|
"""Runtime data for the OneDrive integration."""
|
||||||
|
|
||||||
items: ItemsRequestBuilder
|
client: OneDriveClient
|
||||||
|
token_provider: OneDriveConfigEntryAccessTokenProvider
|
||||||
backup_folder_id: str
|
backup_folder_id: str
|
||||||
|
|
||||||
|
|
||||||
@ -47,29 +46,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
|||||||
|
|
||||||
session = OAuth2Session(hass, entry, implementation)
|
session = OAuth2Session(hass, entry, implementation)
|
||||||
|
|
||||||
auth_provider = BaseBearerTokenAuthenticationProvider(
|
token_provider = OneDriveConfigEntryAccessTokenProvider(session)
|
||||||
access_token_provider=OneDriveConfigEntryAccessTokenProvider(session)
|
|
||||||
)
|
|
||||||
adapter = GraphRequestAdapter(
|
|
||||||
auth_provider=auth_provider,
|
|
||||||
client=create_async_httpx_client(hass, follow_redirects=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
graph_client = GraphServiceClient(
|
client = OneDriveClient(token_provider, async_get_clientsession(hass))
|
||||||
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
|
# get approot, will be created automatically if it does not exist
|
||||||
try:
|
try:
|
||||||
approot = await drive_item.special.by_drive_item_id("approot").get()
|
approot = await client.get_approot()
|
||||||
except APIError as err:
|
except AuthenticationError as err:
|
||||||
if err.response_status_code == 403:
|
|
||||||
raise ConfigEntryAuthFailed(
|
raise ConfigEntryAuthFailed(
|
||||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||||
) from err
|
) from err
|
||||||
|
except (HttpRequestException, OneDriveException, TimeoutError) as err:
|
||||||
_LOGGER.debug("Failed to get approot", exc_info=True)
|
_LOGGER.debug("Failed to get approot", exc_info=True)
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@ -77,24 +65,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
|||||||
translation_placeholders={"folder": "approot"},
|
translation_placeholders={"folder": "approot"},
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
if approot is None or not approot.id:
|
instance_id = await async_get_instance_id(hass)
|
||||||
_LOGGER.debug("Failed to get approot, was None")
|
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(
|
raise ConfigEntryNotReady(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="failed_to_get_folder",
|
translation_key="failed_to_get_folder",
|
||||||
translation_placeholders={"folder": "approot"},
|
translation_placeholders={"folder": backup_folder_name},
|
||||||
)
|
) from err
|
||||||
|
|
||||||
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(
|
entry.runtime_data = OneDriveRuntimeData(
|
||||||
items=drive_item.items,
|
client=client,
|
||||||
backup_folder_id=backup_folder_id,
|
token_provider=token_provider,
|
||||||
|
backup_folder_id=backup_folder.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
_async_notify_backup_listeners_soon(hass)
|
_async_notify_backup_listeners_soon(hass)
|
||||||
@ -116,54 +104,3 @@ def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
|
|||||||
@callback
|
@callback
|
||||||
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
|
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
|
||||||
hass.loop.call_soon(_async_notify_backup_listeners, hass)
|
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."""
|
"""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.const import CONF_ACCESS_TOKEN
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
|
||||||
class OneDriveAccessTokenProvider(AccessTokenProvider):
|
class OneDriveConfigFlowAccessTokenProvider(TokenProvider):
|
||||||
"""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."""
|
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
|
||||||
|
|
||||||
def __init__(self, token: str) -> None:
|
def __init__(self, token: str) -> None:
|
||||||
@ -30,14 +16,12 @@ class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._token = token
|
self._token = token
|
||||||
|
|
||||||
async def get_authorization_token( # pylint: disable=dangerous-default-value
|
def async_get_access_token(self) -> str:
|
||||||
self, uri: str, additional_authentication_context: dict[str, Any] = {}
|
"""Return a valid access token."""
|
||||||
) -> str:
|
|
||||||
"""Return a valid authorization token."""
|
|
||||||
return self._token
|
return self._token
|
||||||
|
|
||||||
|
|
||||||
class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider):
|
class OneDriveConfigEntryAccessTokenProvider(TokenProvider):
|
||||||
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
|
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
|
||||||
|
|
||||||
def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None:
|
def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None:
|
||||||
@ -45,9 +29,6 @@ class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._oauth_session = oauth_session
|
self._oauth_session = oauth_session
|
||||||
|
|
||||||
async def get_authorization_token( # pylint: disable=dangerous-default-value
|
def async_get_access_token(self) -> str:
|
||||||
self, uri: str, additional_authentication_context: dict[str, Any] = {}
|
"""Return a valid access token."""
|
||||||
) -> str:
|
|
||||||
"""Return a valid authorization token."""
|
|
||||||
await self._oauth_session.async_ensure_token_valid()
|
|
||||||
return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN])
|
return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN])
|
||||||
|
@ -2,37 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Concatenate, cast
|
from typing import Any, Concatenate
|
||||||
|
|
||||||
from httpx import Response, TimeoutException
|
from aiohttp import ClientTimeout
|
||||||
from kiota_abstractions.api_error import APIError
|
from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient
|
||||||
from kiota_abstractions.authentication import AnonymousAuthenticationProvider
|
from onedrive_personal_sdk.exceptions import (
|
||||||
from kiota_abstractions.headers_collection import HeadersCollection
|
AuthenticationError,
|
||||||
from kiota_abstractions.method import Method
|
HashMismatchError,
|
||||||
from kiota_abstractions.native_response_handler import NativeResponseHandler
|
OneDriveException,
|
||||||
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 (
|
from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate
|
||||||
CreateUploadSessionPostRequestBody,
|
from onedrive_personal_sdk.models.upload import FileInfo
|
||||||
)
|
|
||||||
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 (
|
from homeassistant.components.backup import (
|
||||||
AgentBackup,
|
AgentBackup,
|
||||||
@ -41,14 +26,14 @@ from homeassistant.components.backup import (
|
|||||||
suggested_filename,
|
suggested_filename,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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 . import OneDriveConfigEntry
|
||||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
|
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(
|
async def async_get_backup_agents(
|
||||||
@ -92,18 +77,18 @@ def handle_backup_errors[_R, **P](
|
|||||||
) -> _R:
|
) -> _R:
|
||||||
try:
|
try:
|
||||||
return await func(self, *args, **kwargs)
|
return await func(self, *args, **kwargs)
|
||||||
except APIError as err:
|
except AuthenticationError as err:
|
||||||
if err.response_status_code == 403:
|
|
||||||
self._entry.async_start_reauth(self._hass)
|
self._entry.async_start_reauth(self._hass)
|
||||||
|
raise BackupAgentError("Authentication error") from err
|
||||||
|
except OneDriveException as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Error during backup in %s: Status %s, message %s",
|
"Error during backup in %s:, message %s",
|
||||||
func.__name__,
|
func.__name__,
|
||||||
err.response_status_code,
|
err,
|
||||||
err.message,
|
|
||||||
)
|
)
|
||||||
_LOGGER.debug("Full error: %s", err, exc_info=True)
|
_LOGGER.debug("Full error: %s", err, exc_info=True)
|
||||||
raise BackupAgentError("Backup operation failed") from err
|
raise BackupAgentError("Backup operation failed") from err
|
||||||
except TimeoutException as err:
|
except TimeoutError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Error during backup in %s: Timeout",
|
"Error during backup in %s: Timeout",
|
||||||
func.__name__,
|
func.__name__,
|
||||||
@ -123,7 +108,8 @@ class OneDriveBackupAgent(BackupAgent):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._entry = entry
|
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._folder_id = entry.runtime_data.backup_folder_id
|
||||||
self.name = entry.title
|
self.name = entry.title
|
||||||
assert entry.unique_id
|
assert entry.unique_id
|
||||||
@ -134,24 +120,12 @@ class OneDriveBackupAgent(BackupAgent):
|
|||||||
self, backup_id: str, **kwargs: Any
|
self, backup_id: str, **kwargs: Any
|
||||||
) -> AsyncIterator[bytes]:
|
) -> AsyncIterator[bytes]:
|
||||||
"""Download a backup file."""
|
"""Download a backup file."""
|
||||||
# this forces the query to return a raw httpx response, but breaks typing
|
item = await self._find_item_by_backup_id(backup_id)
|
||||||
backup = await self._find_item_by_backup_id(backup_id)
|
if item is None:
|
||||||
if backup is None or backup.id is None:
|
|
||||||
raise BackupAgentError("Backup not found")
|
raise BackupAgentError("Backup not found")
|
||||||
|
|
||||||
request_config = (
|
stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT)
|
||||||
ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration(
|
return stream.iter_chunked(1024)
|
||||||
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)
|
|
||||||
|
|
||||||
@handle_backup_errors
|
@handle_backup_errors
|
||||||
async def async_upload_backup(
|
async def async_upload_backup(
|
||||||
@ -163,27 +137,20 @@ class OneDriveBackupAgent(BackupAgent):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Upload a backup."""
|
"""Upload a backup."""
|
||||||
|
|
||||||
# upload file in chunks to support large files
|
file = FileInfo(
|
||||||
upload_session_request_body = CreateUploadSessionPostRequestBody(
|
suggested_filename(backup),
|
||||||
item=DriveItemUploadableProperties(
|
backup.size,
|
||||||
additional_data={
|
self._folder_id,
|
||||||
"@microsoft.graph.conflictBehavior": "fail",
|
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))
|
except HashMismatchError as err:
|
||||||
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:
|
|
||||||
raise BackupAgentError(
|
raise BackupAgentError(
|
||||||
translation_domain=DOMAIN, translation_key="backup_no_upload_session"
|
"Hash validation failed, backup file might be corrupt"
|
||||||
)
|
) from err
|
||||||
|
|
||||||
await self._upload_file(
|
|
||||||
upload_session.upload_url, await open_stream(), backup.size
|
|
||||||
)
|
|
||||||
|
|
||||||
# store metadata in description
|
# store metadata in description
|
||||||
backup_dict = backup.as_dict()
|
backup_dict = backup.as_dict()
|
||||||
@ -191,7 +158,10 @@ class OneDriveBackupAgent(BackupAgent):
|
|||||||
description = json.dumps(backup_dict)
|
description = json.dumps(backup_dict)
|
||||||
_LOGGER.debug("Creating metadata: %s", description)
|
_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
|
@handle_backup_errors
|
||||||
async def async_delete_backup(
|
async def async_delete_backup(
|
||||||
@ -200,35 +170,31 @@ class OneDriveBackupAgent(BackupAgent):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a backup file."""
|
"""Delete a backup file."""
|
||||||
backup = await self._find_item_by_backup_id(backup_id)
|
item = await self._find_item_by_backup_id(backup_id)
|
||||||
if backup is None or backup.id is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
await self._items.by_drive_item_id(backup.id).delete()
|
await self._client.delete_drive_item(item.id)
|
||||||
|
|
||||||
@handle_backup_errors
|
@handle_backup_errors
|
||||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||||
"""List backups."""
|
"""List backups."""
|
||||||
backups: list[AgentBackup] = []
|
return [
|
||||||
items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get()
|
self._backup_from_description(item.description)
|
||||||
if items and (values := items.value):
|
for item in await self._client.list_drive_items(self._folder_id)
|
||||||
for item in values:
|
if item.description and "homeassistant_version" in item.description
|
||||||
if (description := item.description) is None:
|
]
|
||||||
continue
|
|
||||||
if "homeassistant_version" in description:
|
|
||||||
backups.append(self._backup_from_description(description))
|
|
||||||
return backups
|
|
||||||
|
|
||||||
@handle_backup_errors
|
@handle_backup_errors
|
||||||
async def async_get_backup(
|
async def async_get_backup(
|
||||||
self, backup_id: str, **kwargs: Any
|
self, backup_id: str, **kwargs: Any
|
||||||
) -> AgentBackup | None:
|
) -> AgentBackup | None:
|
||||||
"""Return a backup."""
|
"""Return a backup."""
|
||||||
backup = await self._find_item_by_backup_id(backup_id)
|
item = await self._find_item_by_backup_id(backup_id)
|
||||||
if backup is None:
|
return (
|
||||||
return None
|
self._backup_from_description(item.description)
|
||||||
|
if item and item.description
|
||||||
assert backup.description # already checked in _find_item_by_backup_id
|
else None
|
||||||
return self._backup_from_description(backup.description)
|
)
|
||||||
|
|
||||||
def _backup_from_description(self, description: str) -> AgentBackup:
|
def _backup_from_description(self, description: str) -> AgentBackup:
|
||||||
"""Create a backup object from a description."""
|
"""Create a backup object from a description."""
|
||||||
@ -237,91 +203,13 @@ class OneDriveBackupAgent(BackupAgent):
|
|||||||
) # OneDrive encodes the description on save automatically
|
) # OneDrive encodes the description on save automatically
|
||||||
return AgentBackup.from_dict(json.loads(description))
|
return AgentBackup.from_dict(json.loads(description))
|
||||||
|
|
||||||
async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None:
|
async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None:
|
||||||
"""Find a backup item by its backup ID."""
|
"""Find an item by backup ID."""
|
||||||
|
return next(
|
||||||
items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get()
|
(
|
||||||
if items and (values := items.value):
|
item
|
||||||
for item in values:
|
for item in await self._client.list_drive_items(self._folder_id)
|
||||||
if (description := item.description) is None:
|
if item.description and backup_id in item.description
|
||||||
continue
|
),
|
||||||
if backup_id in description:
|
None,
|
||||||
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 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
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from kiota_abstractions.api_error import APIError
|
from onedrive_personal_sdk.clients.client import OneDriveClient
|
||||||
from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider
|
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||||
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.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
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.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||||
from homeassistant.helpers.httpx_client import get_async_client
|
|
||||||
|
|
||||||
from .api import OneDriveConfigFlowAccessTokenProvider
|
from .api import OneDriveConfigFlowAccessTokenProvider
|
||||||
from .const import DOMAIN, OAUTH_SCOPES
|
from .const import DOMAIN, OAUTH_SCOPES
|
||||||
@ -39,48 +36,24 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
|||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
auth_provider = BaseBearerTokenAuthenticationProvider(
|
token_provider = OneDriveConfigFlowAccessTokenProvider(
|
||||||
access_token_provider=OneDriveConfigFlowAccessTokenProvider(
|
|
||||||
cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||||
)
|
)
|
||||||
)
|
|
||||||
adapter = GraphRequestAdapter(
|
graph_client = OneDriveClient(
|
||||||
auth_provider=auth_provider,
|
token_provider, async_get_clientsession(self.hass)
|
||||||
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:
|
try:
|
||||||
response = await request_adapter.get_http_response_message(
|
approot = await graph_client.get_approot()
|
||||||
request_info=request_info, parent_span=parent_span
|
except OneDriveException:
|
||||||
)
|
|
||||||
except APIError:
|
|
||||||
self.logger.exception("Failed to connect to OneDrive")
|
self.logger.exception("Failed to connect to OneDrive")
|
||||||
return self.async_abort(reason="connection_error")
|
return self.async_abort(reason="connection_error")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Unknown error")
|
self.logger.exception("Unknown error")
|
||||||
return self.async_abort(reason="unknown")
|
return self.async_abort(reason="unknown")
|
||||||
|
|
||||||
drive: dict = response.json()
|
await self.async_set_unique_id(approot.parent_reference.drive_id)
|
||||||
|
|
||||||
await self.async_set_unique_id(drive["parentReference"]["driveId"])
|
|
||||||
|
|
||||||
if self.source == SOURCE_REAUTH:
|
if self.source == SOURCE_REAUTH:
|
||||||
reauth_entry = self._get_reauth_entry()
|
reauth_entry = self._get_reauth_entry()
|
||||||
@ -94,10 +67,11 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
|||||||
|
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
user = drive.get("createdBy", {}).get("user", {}).get("displayName")
|
title = (
|
||||||
|
f"{approot.created_by.user.display_name}'s OneDrive"
|
||||||
title = f"{user}'s OneDrive" if user else "OneDrive"
|
if approot.created_by.user and approot.created_by.user.display_name
|
||||||
|
else "OneDrive"
|
||||||
|
)
|
||||||
return self.async_create_entry(title=title, data=data)
|
return self.async_create_entry(title=title, data=data)
|
||||||
|
|
||||||
async def async_step_reauth(
|
async def async_step_reauth(
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/onedrive",
|
"documentation": "https://www.home-assistant.io/integrations/onedrive",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["msgraph", "msgraph-core", "kiota"],
|
"loggers": ["onedrive_personal_sdk"],
|
||||||
"quality_scale": "bronze",
|
"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.",
|
"connection_error": "Failed to connect to OneDrive.",
|
||||||
"wrong_drive": "New account does not contain previously configured OneDrive.",
|
"wrong_drive": "New account does not contain previously configured OneDrive.",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
"failed_to_create_folder": "Failed to create backup folder"
|
|
||||||
},
|
},
|
||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exceptions": {
|
"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": {
|
"authentication_failed": {
|
||||||
"message": "Authentication failed"
|
"message": "Authentication failed"
|
||||||
},
|
},
|
||||||
"failed_to_get_folder": {
|
"failed_to_get_folder": {
|
||||||
"message": "Failed to get {folder} 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
|
# 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
|
||||||
|
|
||||||
@ -1558,6 +1555,9 @@ omnilogic==0.4.5
|
|||||||
# homeassistant.components.ondilo_ico
|
# homeassistant.components.ondilo_ico
|
||||||
ondilo==0.5.0
|
ondilo==0.5.0
|
||||||
|
|
||||||
|
# homeassistant.components.onedrive
|
||||||
|
onedrive-personal-sdk==0.0.1
|
||||||
|
|
||||||
# homeassistant.components.onvif
|
# homeassistant.components.onvif
|
||||||
onvif-zeep-async==3.2.5
|
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
|
# 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
|
||||||
|
|
||||||
@ -1306,6 +1303,9 @@ omnilogic==0.4.5
|
|||||||
# homeassistant.components.ondilo_ico
|
# homeassistant.components.ondilo_ico
|
||||||
ondilo==0.5.0
|
ondilo==0.5.0
|
||||||
|
|
||||||
|
# homeassistant.components.onedrive
|
||||||
|
onedrive-personal-sdk==0.0.1
|
||||||
|
|
||||||
# homeassistant.components.onvif
|
# homeassistant.components.onvif
|
||||||
onvif-zeep-async==3.2.5
|
onvif-zeep-async==3.2.5
|
||||||
|
|
||||||
|
@ -1,18 +1,9 @@
|
|||||||
"""Fixtures for OneDrive tests."""
|
"""Fixtures for OneDrive tests."""
|
||||||
|
|
||||||
from collections.abc import AsyncIterator, Generator
|
from collections.abc import AsyncIterator, Generator
|
||||||
from html import escape
|
|
||||||
from json import dumps
|
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
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
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.application_credentials import (
|
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.core import HomeAssistant
|
||||||
from homeassistant.setup import async_setup_component
|
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
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]:
|
def mock_onedrive_client() -> Generator[MagicMock]:
|
||||||
"""Return a mocked GraphServiceClient."""
|
"""Return a mocked GraphServiceClient."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.onedrive.config_flow.GraphServiceClient",
|
"homeassistant.components.onedrive.config_flow.OneDriveClient",
|
||||||
autospec=True,
|
autospec=True,
|
||||||
) as graph_client,
|
) as onedrive_client,
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.onedrive.GraphServiceClient",
|
"homeassistant.components.onedrive.OneDriveClient",
|
||||||
new=graph_client,
|
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]:
|
||||||
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"
|
yield b"backup data"
|
||||||
|
|
||||||
drive_items.content.get = AsyncMock(
|
client.download_drive_item.return_value = MockStreamReader()
|
||||||
return_value=Response(status_code=200, content=generate_bytes())
|
|
||||||
)
|
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock:
|
def mock_large_file_upload_client() -> Generator[AsyncMock]:
|
||||||
"""Return a mocked DriveItems."""
|
"""Return a mocked LargeFileUploadClient upload."""
|
||||||
return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value
|
with patch(
|
||||||
|
"homeassistant.components.onedrive.backup.LargeFileUploadClient.upload"
|
||||||
|
) as mock_upload:
|
||||||
@pytest.fixture
|
yield mock_upload
|
||||||
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
|
@pytest.fixture
|
||||||
@ -179,10 +121,3 @@ def mock_instance_id() -> Generator[AsyncMock]:
|
|||||||
return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0",
|
return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0",
|
||||||
):
|
):
|
||||||
yield
|
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."""
|
"""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_ID = "1234"
|
||||||
CLIENT_SECRET = "5678"
|
CLIENT_SECRET = "5678"
|
||||||
|
|
||||||
@ -17,3 +30,48 @@ BACKUP_METADATA = {
|
|||||||
"protected": False,
|
"protected": False,
|
||||||
"size": 34519040,
|
"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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from html import escape
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from json import dumps
|
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from httpx import TimeoutException
|
from onedrive_personal_sdk.exceptions import (
|
||||||
from kiota_abstractions.api_error import APIError
|
AuthenticationError,
|
||||||
from msgraph.generated.models.drive_item import DriveItem
|
HashMismatchError,
|
||||||
from msgraph_core.models import LargeFileUploadSession
|
OneDriveException,
|
||||||
|
)
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup
|
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(
|
async def test_agents_get_backup(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
mock_drive_items: MagicMock,
|
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test agent get backup."""
|
"""Test agent get backup."""
|
||||||
|
|
||||||
mock_drive_items.get = AsyncMock(
|
|
||||||
return_value=DriveItem(description=escape(dumps(BACKUP_METADATA)))
|
|
||||||
)
|
|
||||||
backup_id = BACKUP_METADATA["backup_id"]
|
backup_id = BACKUP_METADATA["backup_id"]
|
||||||
client = await hass_ws_client(hass)
|
client = await hass_ws_client(hass)
|
||||||
await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id})
|
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(
|
async def test_agents_delete(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test agent delete backup."""
|
"""Test agent delete backup."""
|
||||||
client = await hass_ws_client(hass)
|
client = await hass_ws_client(hass)
|
||||||
@ -155,37 +150,15 @@ async def test_agents_delete(
|
|||||||
|
|
||||||
assert response["success"]
|
assert response["success"]
|
||||||
assert response["result"] == {"agent_errors": {}}
|
assert response["result"] == {"agent_errors": {}}
|
||||||
mock_drive_items.delete.assert_called_once()
|
mock_onedrive_client.delete_drive_item.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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_agents_upload(
|
async def test_agents_upload(
|
||||||
hass_client: ClientSessionGenerator,
|
hass_client: ClientSessionGenerator,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
|
mock_large_file_upload_client: AsyncMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_adapter: MagicMock,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test agent upload backup."""
|
"""Test agent upload backup."""
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
@ -200,7 +173,6 @@ async def test_agents_upload(
|
|||||||
return_value=test_backup,
|
return_value=test_backup,
|
||||||
),
|
),
|
||||||
patch("pathlib.Path.open") as mocked_open,
|
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""])
|
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
|
||||||
fetch_backup.return_value = test_backup
|
fetch_backup.return_value = test_backup
|
||||||
@ -211,31 +183,22 @@ async def test_agents_upload(
|
|||||||
|
|
||||||
assert resp.status == 201
|
assert resp.status == 201
|
||||||
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||||
mock_drive_items.create_upload_session.post.assert_called_once()
|
mock_large_file_upload_client.assert_called_once()
|
||||||
mock_drive_items.patch.assert_called_once()
|
mock_onedrive_client.update_drive_item.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(
|
async def test_agents_upload_corrupt_upload(
|
||||||
hass_client: ClientSessionGenerator,
|
hass_client: ClientSessionGenerator,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
|
mock_large_file_upload_client: AsyncMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test broken upload session."""
|
"""Test hash validation fails."""
|
||||||
|
mock_large_file_upload_client.side_effect = HashMismatchError("test")
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
test_backup = AgentBackup.from_dict(BACKUP_METADATA)
|
test_backup = AgentBackup.from_dict(BACKUP_METADATA)
|
||||||
|
|
||||||
mock_drive_items.create_upload_session.post = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
||||||
@ -254,152 +217,18 @@ async def test_broken_upload_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status == 201
|
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
|
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||||
mock_drive_items.patch.assert_called_once()
|
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_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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_agents_download(
|
async def test_agents_download(
|
||||||
hass_client: ClientSessionGenerator,
|
hass_client: ClientSessionGenerator,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test agent download backup."""
|
"""Test agent download backup."""
|
||||||
mock_drive_items.get = AsyncMock(
|
|
||||||
return_value=DriveItem(description=escape(dumps(BACKUP_METADATA)))
|
|
||||||
)
|
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
backup_id = BACKUP_METADATA["backup_id"]
|
backup_id = BACKUP_METADATA["backup_id"]
|
||||||
|
|
||||||
@ -408,29 +237,30 @@ async def test_agents_download(
|
|||||||
)
|
)
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert await resp.content.read() == b"backup data"
|
assert await resp.content.read() == b"backup data"
|
||||||
mock_drive_items.content.get.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("side_effect", "error"),
|
("side_effect", "error"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
APIError(response_status_code=500),
|
OneDriveException(),
|
||||||
"Backup operation failed",
|
"Backup operation failed",
|
||||||
),
|
),
|
||||||
(TimeoutException("Timeout"), "Backup operation timed out"),
|
(TimeoutError(), "Backup operation timed out"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_delete_error(
|
async def test_delete_error(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
side_effect: Exception,
|
side_effect: Exception,
|
||||||
error: str,
|
error: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test error during delete."""
|
"""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)
|
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(
|
async def test_agents_backup_not_found(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test backup not found."""
|
"""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"]
|
backup_id = BACKUP_METADATA["backup_id"]
|
||||||
client = await hass_ws_client(hass)
|
client = await hass_ws_client(hass)
|
||||||
await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id})
|
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(
|
async def test_reauth_on_403(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
mock_drive_items: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we re-authenticate on 403."""
|
"""Test we re-authenticate on 403."""
|
||||||
|
|
||||||
mock_drive_items.children.get = AsyncMock(
|
mock_onedrive_client.list_drive_items.side_effect = AuthenticationError(
|
||||||
side_effect=APIError(response_status_code=403)
|
403, "Auth failed"
|
||||||
)
|
)
|
||||||
backup_id = BACKUP_METADATA["backup_id"]
|
backup_id = BACKUP_METADATA["backup_id"]
|
||||||
client = await hass_ws_client(hass)
|
client = await hass_ws_client(hass)
|
||||||
@ -483,7 +334,7 @@ async def test_reauth_on_403(
|
|||||||
|
|
||||||
assert response["success"]
|
assert response["success"]
|
||||||
assert response["result"]["agent_errors"] == {
|
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()
|
await hass.async_block_till_done()
|
||||||
|
@ -3,8 +3,7 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from httpx import Response
|
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||||
from kiota_abstractions.api_error import APIError
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant import config_entries
|
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 homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
from .const import CLIENT_ID
|
from .const import CLIENT_ID, MOCK_APPROOT
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
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"
|
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.usefixtures("current_request_with_host")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("exception", "error"),
|
("exception", "error"),
|
||||||
[
|
[
|
||||||
(Exception, "unknown"),
|
(Exception, "unknown"),
|
||||||
(APIError, "connection_error"),
|
(OneDriveException, "connection_error"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_flow_errors(
|
async def test_flow_errors(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_client_no_auth: ClientSessionGenerator,
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
mock_adapter: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
exception: Exception,
|
exception: Exception,
|
||||||
error: str,
|
error: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test errors during flow."""
|
"""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(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
@ -172,15 +198,12 @@ async def test_reauth_flow_id_changed(
|
|||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
mock_setup_entry: AsyncMock,
|
mock_setup_entry: AsyncMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_adapter: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that the reauth flow fails on a different drive id."""
|
"""Test that the reauth flow fails on a different drive id."""
|
||||||
mock_adapter.get_http_response_message.return_value = Response(
|
app_root = MOCK_APPROOT
|
||||||
status_code=200,
|
app_root.parent_reference.drive_id = "other_drive_id"
|
||||||
json={
|
mock_onedrive_client.get_approot.return_value = app_root
|
||||||
"parentReference": {"driveId": "other_drive_id"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await setup_integration(hass, mock_config_entry)
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from kiota_abstractions.api_error import APIError
|
from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
@ -31,82 +31,31 @@ async def test_load_unload_config_entry(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("side_effect", "state"),
|
("side_effect", "state"),
|
||||||
[
|
[
|
||||||
(APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR),
|
(AuthenticationError(403, "Auth failed"), ConfigEntryState.SETUP_ERROR),
|
||||||
(APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY),
|
(OneDriveException(), ConfigEntryState.SETUP_RETRY),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_approot_errors(
|
async def test_approot_errors(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_get_special_folder: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
side_effect: Exception,
|
side_effect: Exception,
|
||||||
state: ConfigEntryState,
|
state: ConfigEntryState,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test errors during approot retrieval."""
|
"""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)
|
await setup_integration(hass, mock_config_entry)
|
||||||
assert mock_config_entry.state is state
|
assert mock_config_entry.state is state
|
||||||
|
|
||||||
|
|
||||||
async def test_faulty_approot(
|
async def test_get_integration_folder_error(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_get_special_folder: MagicMock,
|
mock_onedrive_client: MagicMock,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test faulty approot retrieval."""
|
"""Test faulty approot retrieval."""
|
||||||
mock_get_special_folder.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 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)
|
await setup_integration(hass, mock_config_entry)
|
||||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
assert "Failed to get backups_9f86d081 folder" in caplog.text
|
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