From c1e5673cbd11b84d7146eaa4fddd07308ebcc447 Mon Sep 17 00:00:00 2001 From: Josef Zweck <josef@zweck.dev> Date: Sun, 23 Feb 2025 14:46:37 +0100 Subject: [PATCH] Allow rename of the backup folder for OneDrive (#138407) --- homeassistant/components/onedrive/__init__.py | 104 ++++++--- homeassistant/components/onedrive/backup.py | 2 +- .../components/onedrive/config_flow.py | 158 +++++++++++-- homeassistant/components/onedrive/const.py | 2 + .../components/onedrive/quality_scale.yaml | 5 +- .../components/onedrive/strings.json | 28 ++- tests/components/onedrive/conftest.py | 113 +++++++++- tests/components/onedrive/const.py | 45 +--- tests/components/onedrive/test_config_flow.py | 212 +++++++++++++++++- tests/components/onedrive/test_init.py | 126 ++++++++++- 10 files changed, 680 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4aa11daf39d..6805b073ea2 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -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 diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index f8a2a6699c4..9c7371bee4b 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -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( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 06c9ec253e3..3374c0369ee 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -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, ) - self._abort_if_unique_id_configured() + if self.source != SOURCE_RECONFIGURE: + self._abort_if_unique_id_configured() + + self.step_data = data - title = ( - f"{approot.created_by.user.display_name}'s OneDrive" - if approot.created_by.user and approot.created_by.user.display_name - else "OneDrive" + 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"{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={ + **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, ) - return self.async_create_entry(title=title, data=data) 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( diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py index 7aefa26ea81..fd21d84369c 100644 --- a/homeassistant/components/onedrive/const.py +++ b/homeassistant/components/onedrive/const.py @@ -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" diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 44754e76f2c..dd9e7f26102 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 27afe3e8a9b..37e19eb68ca 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -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": { diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index ed419c820a9..8ff650012f9 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -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( - "homeassistant.components.onedrive.async_get_instance_id", - return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + with ( + patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value=INSTANCE_ID, + ) as mock_instance_id, + patch( + "homeassistant.components.onedrive.config_flow.async_get_instance_id", + new=mock_instance_id, + ), ): yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 0c04a6f4c82..6e91a7ef0ea 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -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, - ), -) diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 1ae92332075..81cd44bd041 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -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, diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index b4ec138ebf4..41c1966a4ae 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -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.""" + """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_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_9f86d081 folder" in caplog.text + 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 -- GitLab