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