diff --git a/CODEOWNERS b/CODEOWNERS index 61b2eb5b557..bb8545c46b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1695,6 +1695,8 @@ build.json @home-assistant/supervisor /tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner +/homeassistant/components/webdav/ @jpbede +/tests/components/webdav/ @jpbede /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webmin/ @autinerd diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py new file mode 100644 index 00000000000..952a68d829f --- /dev/null +++ b/homeassistant/components/webdav/__init__.py @@ -0,0 +1,70 @@ +"""The WebDAV integration.""" + +from __future__ import annotations + +import logging + +from aiowebdav2.client import Client +from aiowebdav2.exceptions import UnauthorizedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .helpers import async_create_client, async_ensure_path_exists + +type WebDavConfigEntry = ConfigEntry[Client] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Set up WebDAV from a config entry.""" + client = async_create_client( + hass=hass, + url=entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + verify_ssl=entry.data.get(CONF_VERIFY_SSL, True), + ) + + try: + result = await client.check() + except UnauthorizedError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_username_password", + ) from err + + # Check if we can connect to the WebDAV server + # and access the root directory + if not result: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) + + # Ensure the backup directory exists + if not await async_ensure_path_exists( + client, entry.data.get(CONF_BACKUP_PATH, "/") + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_access_or_create_backup_path", + ) + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Unload a WebDAV config entry.""" + return True diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py new file mode 100644 index 00000000000..2c19ca450e3 --- /dev/null +++ b/homeassistant/components/webdav/backup.py @@ -0,0 +1,273 @@ +"""Support for WebDAV backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import logging +from typing import Any, Concatenate + +from aiohttp import ClientTimeout +from aiowebdav2 import Property, PropertyRequest +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +from propcache.api import cached_property + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import json_loads_object + +from . import WebDavConfigEntry +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +METADATA_VERSION = "1" +BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[WebDavConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [WebDavBackupAgent(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. + + :return: A function to unregister the listener. + """ + 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[WebDavBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper(self: WebDavBackupAgent, *args: P.args, **kwargs: P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except UnauthorizedError as err: + raise BackupAgentError("Authentication error") from err + except WebDavError as err: + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Backup operation failed: {err}", + ) 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 + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +class WebDavBackupAgent(BackupAgent): + """Backup agent interface.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: WebDavConfigEntry) -> None: + """Initialize the WebDAV backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @cached_property + def _backup_path(self) -> str: + """Return the path to the backup.""" + return self._entry.data.get(CONF_BACKUP_PATH, "") + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + raise BackupNotFound("Backup not found") + + return await self._client.download_iter( + f"{self._backup_path}/{suggested_filename(backup)}", + timeout=BACKUP_TIMEOUT, + ) + + @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. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + (filename_tar, filename_meta) = suggested_filenames(backup) + + await self._client.upload_iter( + await open_stream(), + f"{self._backup_path}/{filename_tar}", + timeout=BACKUP_TIMEOUT, + ) + + _LOGGER.debug( + "Uploaded backup to %s", + f"{self._backup_path}/{filename_tar}", + ) + + await self._client.upload_iter( + json_dumps(backup.as_dict()), + f"{self._backup_path}/{filename_meta}", + ) + + await self._client.set_property_batch( + f"{self._backup_path}/{filename_meta}", + [ + Property( + namespace="homeassistant", + name="backup_id", + value=backup.backup_id, + ), + Property( + namespace="homeassistant", + name="metadata_version", + value=METADATA_VERSION, + ), + ], + ) + + _LOGGER.debug( + "Uploaded metadata file for %s", + f"{self._backup_path}/{filename_meta}", + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + return + + (filename_tar, filename_meta) = suggested_filenames(backup) + backup_path = f"{self._backup_path}/{filename_tar}" + + await self._client.clean(backup_path) + await self._client.clean(f"{self._backup_path}/{filename_meta}") + + _LOGGER.debug( + "Deleted backup at %s", + backup_path, + ) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + metadata_files = await self._list_metadata_files() + return [ + await self._download_metadata(metadata_file) + for metadata_file in metadata_files + ] + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _list_metadata_files(self) -> list[str]: + """List metadata files.""" + files = await self._client.list_with_infos(self._backup_path) + return [ + file["path"] + for file in files + if file["path"].endswith(".json") + and await self._is_current_metadata_version(file["path"]) + ] + + async def _is_current_metadata_version(self, path: str) -> bool: + """Check if is current metadata version.""" + metadata_version = await self._client.get_property( + path, + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), + ) + return metadata_version.value == METADATA_VERSION if metadata_version else False + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + for metadata_file in metadata_files: + remote_backup_id = await self._client.get_property( + metadata_file, + PropertyRequest( + namespace="homeassistant", + name="backup_id", + ), + ) + if remote_backup_id and remote_backup_id.value == backup_id: + return await self._download_metadata(metadata_file) + + return None + + async def _download_metadata(self, path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py new file mode 100644 index 00000000000..f75544d25ad --- /dev/null +++ b/homeassistant/components/webdav/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for the WebDAV integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiowebdav2.exceptions import UnauthorizedError +import voluptuous as vol +import yarl + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_BACKUP_PATH, DOMAIN +from .helpers import async_create_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + ) + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ) + ), + vol.Optional(CONF_BACKUP_PATH, default="/"): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WebDAV.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = async_create_client( + hass=self.hass, + url=user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input.get(CONF_VERIFY_SSL, True), + ) + + # Check if we can connect to the WebDAV server + # .check() already does the most of the error handling and will return True + # if we can access the root directory + try: + result = await client.check() + except UnauthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + if result: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + parsed_url = yarl.URL(user_input[CONF_URL]) + return self.async_create_entry( + title=f"{user_input[CONF_USERNAME]}@{parsed_url.host}", + data=user_input, + ) + + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/webdav/const.py b/homeassistant/components/webdav/const.py new file mode 100644 index 00000000000..faf8ce77ca5 --- /dev/null +++ b/homeassistant/components/webdav/const.py @@ -0,0 +1,13 @@ +"""Constants for the WebDAV integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "webdav" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +CONF_BACKUP_PATH = "backup_path" diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py new file mode 100644 index 00000000000..9f91ed3bdb3 --- /dev/null +++ b/homeassistant/components/webdav/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions for the WebDAV component.""" + +from aiowebdav2.client import Client, ClientOptions + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@callback +def async_create_client( + *, + hass: HomeAssistant, + url: str, + username: str, + password: str, + verify_ssl: bool = False, +) -> Client: + """Create a WebDAV client.""" + return Client( + url=url, + username=username, + password=password, + options=ClientOptions( + verify_ssl=verify_ssl, + session=async_get_clientsession(hass), + ), + ) + + +async def async_ensure_path_exists(client: Client, path: str) -> bool: + """Ensure that a path exists recursively on the WebDAV server.""" + parts = path.strip("/").split("/") + for i in range(1, len(parts) + 1): + sub_path = "/".join(parts[:i]) + if not await client.check(sub_path) and not await client.mkdir(sub_path): + return False + + return True diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json new file mode 100644 index 00000000000..a1ac779afc8 --- /dev/null +++ b/homeassistant/components/webdav/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "webdav", + "name": "WebDAV", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webdav", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aiowebdav2"], + "quality_scale": "bronze", + "requirements": ["aiowebdav2==0.2.2"] +} diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml new file mode 100644 index 00000000000..560626fda7e --- /dev/null +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -0,0 +1,145 @@ +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: + status: exempt + comment: Integration does not register custom actions. + 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: todo + 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: + status: done + comment: | + No known limitations. + 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: todo + 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: todo + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json new file mode 100644 index 00000000000..57117cdd9de --- /dev/null +++ b/homeassistant/components/webdav/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "backup_path": "Backup path", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "The URL of the WebDAV server. Check with your provider for the correct URL.", + "username": "The username for the WebDAV server.", + "password": "The password for the WebDAV server.", + "backup_path": "Define the path where the backups should be located (will be created automatically if it does not exist).", + "verify_ssl": "Whether to verify the SSL certificate of the server. If you are using a self-signed certificate, do not select this option." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "exceptions": { + "invalid_username_password": { + "message": "Invalid username or password" + }, + "cannot_connect": { + "message": "Cannot connect to WebDAV server" + }, + "cannot_access_or_create_backup_path": { + "message": "Cannot access or create backup path. Please check the path and permissions." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c92235aae47..de581c65297 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -692,6 +692,7 @@ FLOWS = { "weatherflow", "weatherflow_cloud", "weatherkit", + "webdav", "webmin", "webostv", "weheat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6f4315c43dc..41083ee8e8c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7092,6 +7092,12 @@ } } }, + "webdav": { + "name": "WebDAV", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "webmin": { "name": "Webmin", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 1ce88e0f55d..87dd9bb204e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,6 +421,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6588b06c41..f55ea287d37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -403,6 +403,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py new file mode 100644 index 00000000000..33e0222fb34 --- /dev/null +++ b/tests/components/webdav/__init__.py @@ -0,0 +1 @@ +"""Tests for the WebDAV integration.""" diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py new file mode 100644 index 00000000000..ccd3437aaa0 --- /dev/null +++ b/tests/components/webdav/conftest.py @@ -0,0 +1,80 @@ +"""Common fixtures for the WebDAV tests.""" + +from collections.abc import AsyncIterator, Generator +from json import dumps +from unittest.mock import AsyncMock, patch + +from aiowebdav2 import Property, PropertyRequest +import pytest + +from homeassistant.components.webdav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +from .const import ( + BACKUP_METADATA, + MOCK_GET_PROPERTY_BACKUP_ID, + MOCK_GET_PROPERTY_METADATA_VERSION, + MOCK_LIST_WITH_INFOS, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webdav.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + +def _get_property(path: str, request: PropertyRequest) -> Property: + """Return the property of a file.""" + if path.endswith(".json") and request.name == "metadata_version": + return MOCK_GET_PROPERTY_METADATA_VERSION + + return MOCK_GET_PROPERTY_BACKUP_ID + + +async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: + """Mock the download function.""" + if path.endswith(".json"): + yield dumps(BACKUP_METADATA).encode() + + yield b"backup data" + + +@pytest.fixture(name="webdav_client") +def mock_webdav_client() -> Generator[AsyncMock]: + """Mock the aiowebdav client.""" + with ( + patch( + "homeassistant.components.webdav.helpers.Client", + autospec=True, + ) as mock_webdav_client, + ): + mock = mock_webdav_client.return_value + mock.check.return_value = True + mock.mkdir.return_value = True + mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.download_iter.side_effect = _download_mock + mock.upload_iter.return_value = None + mock.clean.return_value = None + mock.get_property.side_effect = _get_property + yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py new file mode 100644 index 00000000000..777008b07a5 --- /dev/null +++ b/tests/components/webdav/const.py @@ -0,0 +1,52 @@ +"""Constants for WebDAV tests.""" + +from aiowebdav2 import Property + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "protected": False, + "size": 34519040, +} + +MOCK_LIST_WITH_INFOS = [ + { + "content_type": "application/x-tar", + "created": "2025-02-10T17:47:22Z", + "etag": '"84d7d000-62dcd4ce886b4"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "size": "2228736000", + }, + { + "content_type": "application/json", + "created": "2025-02-10T17:47:22Z", + "etag": '"8d0-62dcd4cec050a"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", + "size": "2256", + }, +] + +MOCK_GET_PROPERTY_METADATA_VERSION = Property( + namespace="homeassistant", + name="metadata_version", + value="1", +) + +MOCK_GET_PROPERTY_BACKUP_ID = Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", +) diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py new file mode 100644 index 00000000000..b02fb2e9628 --- /dev/null +++ b/tests/components/webdav/test_backup.py @@ -0,0 +1,323 @@ +"""Test the backups for WebDAV.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import Mock, patch + +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.webdav.backup import async_register_backup_agents_listener +from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> AsyncGenerator[None]: + """Set up webdav 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, {}) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + 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.entry_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": [], + "agents": { + "webdav.01JKXV07ASC62D620DGYNG2R8H": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + 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": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> 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": {}} + assert webdav_client.clean.call_count == 2 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> 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, + ): + 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.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert webdav_client.upload_iter.call_count == 2 + assert webdav_client.set_property_batch.call_count == 1 + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + 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.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we get not found on a not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + WebDavError("Unknown path"), + "Backup operation failed: Unknown path", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + webdav_client.clean.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.entry_id}": error} + } + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + webdav_client.list_with_infos.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test backup not found.""" + webdav_client.list_with_infos.return_value = [] + 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_raises_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we raise on 403.""" + webdav_client.list_with_infos.side_effect = UnauthorizedError( + "https://webdav.example.com" + ) + 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.entry_id}": "Authentication error" + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = AsyncMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + # make sure it's the last listener + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py new file mode 100644 index 00000000000..eb887edb1a1 --- /dev/null +++ b/tests/components/webdav/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the WebDAV config flow.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import UnauthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test we get the form and create a entry on success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert result["data"] == { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + } + assert len(webdav_client.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test to handle exceptions.""" + webdav_client.check.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # reset and test for success + webdav_client.check.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_unauthorized( + hass: HomeAssistant, + webdav_client: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test to handle unauthorized.""" + webdav_client.check.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # reset and test for success + webdav_client.check.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> None: + """Test we get the form and create a entry on success.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"