Migrate OneDrive to onedrive_personal_sdk library (#137064)

This commit is contained in:
Josef Zweck 2025-02-03 16:25:58 +01:00 committed by GitHub
parent 05ca80f4ba
commit 628e1ffb84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 307 additions and 724 deletions

View File

@ -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

View File

@ -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])

View File

@ -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)
) )
) except HashMismatchError as err:
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:
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)

View File

@ -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(
auth_provider=auth_provider,
client=get_async_client(self.hass),
) )
graph_client = GraphServiceClient( graph_client = OneDriveClient(
request_adapter=adapter, token_provider, async_get_clientsession(self.hass)
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(

View File

@ -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"]
} }

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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]:
yield b"backup data"
drives = client.drives.by_drive_id.return_value client.download_drive_item.return_value = MockStreamReader()
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())
)
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

View File

@ -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,
)

View File

@ -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()

View File

@ -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)

View File

@ -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