Allow rename of the backup folder for OneDrive (#138407)

This commit is contained in:
Josef Zweck 2025-02-23 14:46:37 +01:00 committed by GitHub
parent 800fe1b01e
commit c1e5673cbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 681 additions and 116 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from html import unescape
from json import dumps, loads
import logging
@ -10,10 +11,10 @@ from typing import cast
from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.exceptions import (
AuthenticationError,
HttpRequestException,
NotFoundError,
OneDriveException,
)
from onedrive_personal_sdk.models.items import ItemUpdate
from onedrive_personal_sdk.models.items import Item, ItemUpdate
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
@ -25,7 +26,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import (
OneDriveConfigEntry,
OneDriveRuntimeData,
@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
# get approot, will be created automatically if it does not exist
try:
approot = await client.get_approot()
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (HttpRequestException, OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to get approot", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": "approot"},
) from err
approot = await _handle_item_operation(client.get_approot, "approot")
folder_name = entry.data[CONF_FOLDER_NAME]
instance_id = await async_get_instance_id(hass)
backup_folder_name = f"backups_{instance_id[:8]}"
try:
backup_folder = await client.create_folder(
parent_id=approot.id, name=backup_folder_name
backup_folder = await _handle_item_operation(
lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]),
folder_name,
)
except NotFoundError:
_LOGGER.debug("Creating backup folder %s", folder_name)
backup_folder = await _handle_item_operation(
lambda: client.create_folder(parent_id=approot.id, name=folder_name),
folder_name,
)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id}
)
# write instance id to description
if backup_folder.description != (instance_id := await async_get_instance_id(hass)):
await _handle_item_operation(
lambda: client.update_drive_item(
backup_folder.id, ItemUpdate(description=instance_id)
),
folder_name,
)
# update in case folder was renamed manually inside OneDrive
if backup_folder.name != entry.data[CONF_FOLDER_NAME]:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name}
)
except (HttpRequestException, OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to create backup folder", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": backup_folder_name},
) from err
coordinator = OneDriveUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
@ -152,3 +158,47 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -
data=ItemUpdate(description=""),
)
_LOGGER.debug("Migrated backup file %s", file.name)
async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1:
_LOGGER.debug(
"Migrating OneDrive config entry from version %s.%s", version, minor_version
)
instance_id = await async_get_instance_id(hass)
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_FOLDER_ID: "id", # will be updated during setup_entry
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
},
)
_LOGGER.debug("Migration to version 1.2 successful")
return True
async def _handle_item_operation(
func: Callable[[], Awaitable[Item]], folder: str
) -> Item:
try:
return await func()
except NotFoundError:
raise
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to get approot", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": folder},
) from err

View File

@ -74,7 +74,7 @@ def async_register_backup_agents_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."""
"""Handle backup errors."""
@wraps(func)
async def wrapper(

View File

@ -8,22 +8,47 @@ from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException
from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
from .const import (
CONF_DELETE_PERMANENTLY,
CONF_FOLDER_ID,
CONF_FOLDER_NAME,
DOMAIN,
OAUTH_SCOPES,
)
from .coordinator import OneDriveConfigEntry
FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str})
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle OneDrive OAuth2 authentication."""
DOMAIN = DOMAIN
MINOR_VERSION = 2
client: OneDriveClient
approot: AppRoot
def __init__(self) -> None:
"""Initialize the OneDrive config flow."""
super().__init__()
self.step_data: dict[str, Any] = {}
@property
def logger(self) -> logging.Logger:
@ -35,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(OAUTH_SCOPES)}
@property
def apps_folder(self) -> str:
"""Return the name of the Apps folder (translated)."""
return (
path.split("/")[-1]
if (path := self.approot.parent_reference.path)
else "Apps"
)
async def async_oauth_create_entry(
self,
data: dict[str, Any],
@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def get_access_token() -> str:
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
graph_client = OneDriveClient(
self.client = OneDriveClient(
get_access_token, async_get_clientsession(self.hass)
)
try:
approot = await graph_client.get_approot()
self.approot = await self.client.get_approot()
except OneDriveException:
self.logger.exception("Failed to connect to OneDrive")
return self.async_abort(reason="connection_error")
@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self.logger.exception("Unknown error")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(approot.parent_reference.drive_id)
await self.async_set_unique_id(self.approot.parent_reference.drive_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
if self.source != SOURCE_USER:
self._abort_if_unique_id_mismatch(
reason="wrong_drive",
)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
entry=reauth_entry,
data=data,
)
if self.source != SOURCE_RECONFIGURE:
self._abort_if_unique_id_configured()
self.step_data = data
if self.source == SOURCE_RECONFIGURE:
return await self.async_step_reconfigure_folder()
return await self.async_step_folder_name()
async def async_step_folder_name(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step to ask for the folder name."""
errors: dict[str, str] = {}
instance_id = await async_get_instance_id(self.hass)
if user_input is not None:
try:
folder = await self.client.create_folder(
self.approot.id, user_input[CONF_FOLDER_NAME]
)
except OneDriveException:
self.logger.debug("Failed to create folder", exc_info=True)
errors["base"] = "folder_creation_error"
else:
if folder.description and folder.description != instance_id:
errors[CONF_FOLDER_NAME] = "folder_already_in_use"
if not errors:
title = (
f"{approot.created_by.user.display_name}'s OneDrive"
if approot.created_by.user and approot.created_by.user.display_name
f"{self.approot.created_by.user.display_name}'s OneDrive"
if self.approot.created_by.user
and self.approot.created_by.user.display_name
else "OneDrive"
)
return self.async_create_entry(title=title, data=data)
return self.async_create_entry(
title=title,
data={
**self.step_data,
CONF_FOLDER_ID: folder.id,
CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME],
},
)
default_folder_name = (
f"backups_{instance_id[:8]}"
if user_input is None
else user_input[CONF_FOLDER_NAME]
)
return self.async_show_form(
step_id="folder_name",
data_schema=self.add_suggested_values_to_schema(
FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name}
),
description_placeholders={
"apps_folder": self.apps_folder,
"approot": self.approot.name,
},
errors=errors,
)
async def async_step_reconfigure_folder(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure the folder name."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
if (
new_folder_name := user_input[CONF_FOLDER_NAME]
) != reconfigure_entry.data[CONF_FOLDER_NAME]:
try:
await self.client.update_drive_item(
reconfigure_entry.data[CONF_FOLDER_ID],
ItemUpdate(name=new_folder_name),
)
except OneDriveException:
self.logger.debug("Failed to update folder", exc_info=True)
errors["base"] = "folder_rename_error"
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name},
)
return self.async_show_form(
step_id="reconfigure_folder",
data_schema=self.add_suggested_values_to_schema(
FOLDER_NAME_SCHEMA,
{CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]},
),
description_placeholders={
"apps_folder": self.apps_folder,
"approot": self.approot.name,
},
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@ -92,6 +218,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure the entry."""
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(

View File

@ -6,6 +6,8 @@ from typing import Final
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "onedrive"
CONF_FOLDER_NAME: Final = "folder_name"
CONF_FOLDER_ID: Final = "folder_id"
CONF_DELETE_PERMANENTLY: Final = "delete_permanently"

View File

@ -73,10 +73,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: exempt
comment: |
Nothing to reconfigure.
reconfiguration-flow: done
repair-issues: done
stale-devices:
status: exempt

View File

@ -7,6 +7,26 @@
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The OneDrive integration needs to re-authenticate your account"
},
"folder_name": {
"title": "Pick a folder name",
"description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`",
"data": {
"folder_name": "Folder name"
},
"data_description": {
"folder_name": "Name of the folder"
}
},
"reconfigure_folder": {
"title": "Change the folder name",
"description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.",
"data": {
"folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]"
},
"data_description": {
"folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]"
}
}
},
"abort": {
@ -23,10 +43,16 @@
"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%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"error": {
"folder_rename_error": "Failed to rename folder",
"folder_creation_error": "Failed to create folder",
"folder_already_in_use": "Folder already used for backups from another Home Assistant instance"
}
},
"options": {

View File

@ -5,13 +5,28 @@ from json import dumps
import time
from unittest.mock import AsyncMock, MagicMock, patch
from onedrive_personal_sdk.const import DriveState, DriveType
from onedrive_personal_sdk.models.items import (
AppRoot,
Drive,
DriveQuota,
Folder,
IdentitySet,
ItemParentReference,
User,
)
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES
from homeassistant.components.onedrive.const import (
CONF_FOLDER_ID,
CONF_FOLDER_NAME,
DOMAIN,
OAUTH_SCOPES,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -19,10 +34,9 @@ from .const import (
BACKUP_METADATA,
CLIENT_ID,
CLIENT_SECRET,
MOCK_APPROOT,
IDENTITY_SET,
INSTANCE_ID,
MOCK_BACKUP_FILE,
MOCK_BACKUP_FOLDER,
MOCK_DRIVE,
MOCK_METADATA_FILE,
)
@ -66,8 +80,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
"expires_at": expires_at,
"scope": " ".join(scopes),
},
CONF_FOLDER_NAME: "backups_123",
CONF_FOLDER_ID: "my_folder_id",
},
unique_id="mock_drive_id",
minor_version=2,
)
@ -87,14 +104,80 @@ def mock_onedrive_client_init() -> Generator[MagicMock]:
yield onedrive_client
@pytest.fixture
def mock_approot() -> AppRoot:
"""Return a mocked approot."""
return AppRoot(
id="id",
child_count=0,
size=0,
name="name",
parent_reference=ItemParentReference(
drive_id="mock_drive_id", id="id", path="path"
),
created_by=IdentitySet(
user=User(
display_name="John Doe",
id="id",
email="john@doe.com",
)
),
)
@pytest.fixture
def mock_drive() -> Drive:
"""Return a mocked drive."""
return Drive(
id="mock_drive_id",
name="My Drive",
drive_type=DriveType.PERSONAL,
owner=IDENTITY_SET,
quota=DriveQuota(
deleted=5,
remaining=805306368,
state=DriveState.NEARING,
total=5368709120,
used=4250000000,
),
)
@pytest.fixture
def mock_folder() -> Folder:
"""Return a mocked backup folder."""
return Folder(
id="my_folder_id",
name="name",
size=0,
child_count=0,
description="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0",
parent_reference=ItemParentReference(
drive_id="mock_drive_id", id="id", path="path"
),
created_by=IdentitySet(
user=User(
display_name="John Doe",
id="id",
email="john@doe.com",
),
),
)
@pytest.fixture(autouse=True)
def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]:
def mock_onedrive_client(
mock_onedrive_client_init: MagicMock,
mock_approot: AppRoot,
mock_drive: Drive,
mock_folder: Folder,
) -> Generator[MagicMock]:
"""Return a mocked GraphServiceClient."""
client = mock_onedrive_client_init.return_value
client.get_approot.return_value = MOCK_APPROOT
client.create_folder.return_value = MOCK_BACKUP_FOLDER
client.get_approot.return_value = mock_approot
client.create_folder.return_value = mock_folder
client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE]
client.get_drive_item.return_value = MOCK_BACKUP_FILE
client.get_drive_item.return_value = mock_folder
client.upload_file.return_value = MOCK_METADATA_FILE
class MockStreamReader:
@ -105,7 +188,7 @@ def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[Magi
return dumps(BACKUP_METADATA).encode()
client.download_drive_item.return_value = MockStreamReader()
client.get_drive.return_value = MOCK_DRIVE
client.get_drive.return_value = mock_drive
return client
@ -131,8 +214,14 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture(autouse=True)
def mock_instance_id() -> Generator[AsyncMock]:
"""Mock the instance ID."""
with patch(
with (
patch(
"homeassistant.components.onedrive.async_get_instance_id",
return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0",
return_value=INSTANCE_ID,
) as mock_instance_id,
patch(
"homeassistant.components.onedrive.config_flow.async_get_instance_id",
new=mock_instance_id,
),
):
yield

View File

@ -3,13 +3,8 @@
from html import escape
from json import dumps
from onedrive_personal_sdk.const import DriveState, DriveType
from onedrive_personal_sdk.models.items import (
AppRoot,
Drive,
DriveQuota,
File,
Folder,
Hashes,
IdentitySet,
ItemParentReference,
@ -34,6 +29,8 @@ BACKUP_METADATA = {
"size": 34519040,
}
INSTANCE_ID = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0"
IDENTITY_SET = IdentitySet(
user=User(
display_name="John Doe",
@ -42,28 +39,6 @@ IDENTITY_SET = IdentitySet(
)
)
MOCK_APPROOT = AppRoot(
id="id",
child_count=0,
size=0,
name="name",
parent_reference=ItemParentReference(
drive_id="mock_drive_id", id="id", path="path"
),
created_by=IDENTITY_SET,
)
MOCK_BACKUP_FOLDER = Folder(
id="id",
name="name",
size=0,
child_count=0,
parent_reference=ItemParentReference(
drive_id="mock_drive_id", id="id", path="path"
),
created_by=IDENTITY_SET,
)
MOCK_BACKUP_FILE = File(
id="id",
name="23e64aec.tar",
@ -75,7 +50,6 @@ MOCK_BACKUP_FILE = File(
quick_xor_hash="hash",
),
mime_type="application/x-tar",
description="",
created_by=IDENTITY_SET,
)
@ -101,18 +75,3 @@ MOCK_METADATA_FILE = File(
),
created_by=IDENTITY_SET,
)
MOCK_DRIVE = Drive(
id="mock_drive_id",
name="My Drive",
drive_type=DriveType.PERSONAL,
owner=IDENTITY_SET,
quota=DriveQuota(
deleted=5,
remaining=805306368,
state=DriveState.NEARING,
total=5368709120,
used=4250000000,
),
)

View File

@ -4,11 +4,14 @@ from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock
from onedrive_personal_sdk.exceptions import OneDriveException
from onedrive_personal_sdk.models.items import AppRoot, Folder, ItemUpdate
import pytest
from homeassistant import config_entries
from homeassistant.components.onedrive.const import (
CONF_DELETE_PERMANENTLY,
CONF_FOLDER_ID,
CONF_FOLDER_NAME,
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
@ -20,7 +23,7 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from . import setup_integration
from .const import CLIENT_ID, MOCK_APPROOT
from .const import CLIENT_ID
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@ -85,6 +88,11 @@ async def test_full_flow(
token_callback = mock_onedrive_client_init.call_args[0][0]
assert await token_callback() == "mock-access-token"
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@ -92,6 +100,8 @@ async def test_full_flow(
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"
assert result["data"][CONF_FOLDER_NAME] == "myFolder"
assert result["data"][CONF_FOLDER_ID] == "my_folder_id"
@pytest.mark.usefixtures("current_request_with_host")
@ -101,10 +111,11 @@ async def test_full_flow_with_owner_not_found(
aioclient_mock: AiohttpClientMocker,
mock_setup_entry: AsyncMock,
mock_onedrive_client: MagicMock,
mock_approot: MagicMock,
) -> None:
"""Ensure we get a default title if the drive's owner can't be read."""
mock_onedrive_client.get_approot.return_value.created_by.user = None
mock_approot.created_by.user = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -112,6 +123,11 @@ async def test_full_flow_with_owner_not_found(
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.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@ -119,6 +135,94 @@ async def test_full_flow_with_owner_not_found(
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"
assert result["data"][CONF_FOLDER_NAME] == "myFolder"
assert result["data"][CONF_FOLDER_ID] == "my_folder_id"
mock_onedrive_client.reset_mock()
@pytest.mark.usefixtures("current_request_with_host")
async def test_folder_already_in_use(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry: AsyncMock,
mock_onedrive_client: MagicMock,
mock_instance_id: AsyncMock,
mock_folder: Folder,
) -> None:
"""Ensure a folder that is already in use is not allowed."""
mock_folder.description = "1234"
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.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {CONF_FOLDER_NAME: "folder_already_in_use"}
# clear error and try again
mock_onedrive_client.create_folder.return_value.description = mock_instance_id
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
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"
assert result["data"][CONF_FOLDER_NAME] == "myFolder"
assert result["data"][CONF_FOLDER_ID] == "my_folder_id"
@pytest.mark.usefixtures("current_request_with_host")
async def test_error_during_folder_creation(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry: AsyncMock,
mock_onedrive_client: MagicMock,
) -> None:
"""Ensure we can create the backup folder."""
mock_onedrive_client.create_folder.side_effect = OneDriveException()
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.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "folder_creation_error"}
mock_onedrive_client.create_folder.side_effect = None
# clear error and try again
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
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"
assert result["data"][CONF_FOLDER_NAME] == "myFolder"
assert result["data"][CONF_FOLDER_ID] == "my_folder_id"
@pytest.mark.usefixtures("current_request_with_host")
@ -205,11 +309,11 @@ async def test_reauth_flow_id_changed(
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
mock_approot: AppRoot,
) -> None:
"""Test that the reauth flow fails on a different drive id."""
app_root = MOCK_APPROOT
app_root.parent_reference.drive_id = "other_drive_id"
mock_onedrive_client.get_approot.return_value = app_root
mock_approot.parent_reference.drive_id = "other_drive_id"
await setup_integration(hass, mock_config_entry)
@ -226,6 +330,104 @@ async def test_reauth_flow_id_changed(
assert result["reason"] == "wrong_drive"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reconfigure_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_onedrive_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Testing reconfgure flow."""
await setup_integration(hass, mock_config_entry)
result = await mock_config_entry.start_reconfigure_flow(hass)
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.FORM
assert result["step_id"] == "reconfigure_folder"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "newFolder"}
)
assert result["type"] is FlowResultType.ABORT
mock_onedrive_client.update_drive_item.assert_called_once_with(
mock_config_entry.data[CONF_FOLDER_ID], ItemUpdate(name="newFolder")
)
assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder"
assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token"
assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reconfigure_flow_error(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_onedrive_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Testing reconfgure flow errors."""
mock_config_entry.add_to_hass(hass)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
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.FORM
assert result["step_id"] == "reconfigure_folder"
mock_onedrive_client.update_drive_item.side_effect = OneDriveException()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "newFolder"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_folder"
assert result["errors"] == {"base": "folder_rename_error"}
# clear side effect
mock_onedrive_client.update_drive_item.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_FOLDER_NAME: "newFolder"}
)
assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder"
assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token"
assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reconfigure_flow_id_changed(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
mock_approot: AppRoot,
) -> None:
"""Test that the reconfigure flow fails on a different drive id."""
mock_approot.parent_reference.drive_id = "other_drive_id"
mock_config_entry.add_to_hass(hass)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
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"
async def test_options_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@ -1,22 +1,31 @@
"""Test the OneDrive setup."""
from copy import deepcopy
from copy import copy
from html import escape
from json import dumps
from unittest.mock import MagicMock
from onedrive_personal_sdk.const import DriveState
from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
from onedrive_personal_sdk.exceptions import (
AuthenticationError,
NotFoundError,
OneDriveException,
)
from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.onedrive.const import DOMAIN
from homeassistant.components.onedrive.const import (
CONF_FOLDER_ID,
CONF_FOLDER_NAME,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from . import setup_integration
from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE
from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE
from tests.common import MockConfigEntry
@ -72,11 +81,64 @@ async def test_get_integration_folder_error(
mock_onedrive_client: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test faulty approot retrieval."""
mock_onedrive_client.create_folder.side_effect = OneDriveException()
"""Test faulty integration folder retrieval."""
mock_onedrive_client.get_drive_item.side_effect = OneDriveException()
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert "Failed to get backups_9f86d081 folder" in caplog.text
assert "Failed to get backups_123 folder" in caplog.text
async def test_get_integration_folder_creation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
mock_approot: AppRoot,
mock_folder: Folder,
) -> None:
"""Test faulty integration folder creation."""
folder_name = copy(mock_config_entry.data[CONF_FOLDER_NAME])
mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_onedrive_client.create_folder.assert_called_once_with(
parent_id=mock_approot.id,
name=folder_name,
)
# ensure the folder id and name are updated
assert mock_config_entry.data[CONF_FOLDER_ID] == mock_folder.id
assert mock_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name
async def test_get_integration_folder_creation_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test faulty integration folder creation error."""
mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found")
mock_onedrive_client.create_folder.side_effect = OneDriveException()
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert "Failed to get backups_123 folder" in caplog.text
async def test_update_instance_id_description(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
mock_folder: Folder,
) -> None:
"""Test we write the instance id to the folder."""
mock_folder.description = ""
await setup_integration(hass, mock_config_entry)
await hass.async_block_till_done()
mock_onedrive_client.update_drive_item.assert_called_with(
mock_folder.id, ItemUpdate(description=INSTANCE_ID)
)
async def test_migrate_metadata_files(
@ -125,12 +187,13 @@ async def test_device(
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
mock_drive: Drive,
) -> None:
"""Test the device."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)})
device = device_registry.async_get_device({(DOMAIN, mock_drive.id)})
assert device
assert device == snapshot
@ -154,17 +217,62 @@ async def test_data_cap_issues(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
mock_drive: Drive,
drive_state: DriveState,
issue_key: str,
issue_exists: bool,
) -> None:
"""Make sure we get issues for high data usage."""
mock_drive = deepcopy(MOCK_DRIVE)
assert mock_drive.quota
mock_drive.quota.state = drive_state
mock_onedrive_client.get_drive.return_value = mock_drive
await setup_integration(hass, mock_config_entry)
issue_registry = ir.async_get(hass)
issue = issue_registry.async_get_issue(DOMAIN, issue_key)
assert (issue is not None) == issue_exists
async def test_1_1_to_1_2_migration(
hass: HomeAssistant,
mock_onedrive_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_folder: Folder,
) -> None:
"""Test migration from 1.1 to 1.2."""
old_config_entry = MockConfigEntry(
unique_id="mock_drive_id",
title="John Doe's OneDrive",
domain=DOMAIN,
data={
"auth_implementation": mock_config_entry.data["auth_implementation"],
"token": mock_config_entry.data["token"],
},
)
# will always 404 after migration, because of dummy id
mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found")
await setup_integration(hass, old_config_entry)
assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id
assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name
async def test_migration_guard_against_major_downgrade(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration guards against major downgrades."""
old_config_entry = MockConfigEntry(
unique_id="mock_drive_id",
title="John Doe's OneDrive",
domain=DOMAIN,
data={
"auth_implementation": mock_config_entry.data["auth_implementation"],
"token": mock_config_entry.data["token"],
},
version=2,
)
await setup_integration(hass, old_config_entry)
assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR