Add OneDrive as backup provider (#135121)

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
Josef Zweck 2025-01-28 15:57:46 +01:00 committed by GitHub
parent 3d7e3590d4
commit 5695582387
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1776 additions and 0 deletions

View File

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

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

View File

@ -11,6 +11,7 @@
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"xbox"
]
}

View 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

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

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

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

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

View 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"
)

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

View 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

View 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"
}
}
}

View File

@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [
"neato",
"nest",
"netatmo",
"onedrive",
"point",
"senz",
"spotify",

View File

@ -434,6 +434,7 @@ FLOWS = {
"omnilogic",
"oncue",
"ondilo_ico",
"onedrive",
"onewire",
"onkyo",
"onvif",

View File

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

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

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

View File

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

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

View 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

View 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,
}

View 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

View 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"

View 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