Add WebDAV backup agent (#137721)

* Add WebDAV backup agent

* Process code review

* Increase timeout for large uploads

* Make metadata file based

* Update IQS

* Grammar

* Move to aiowebdav2

* Update helper text

* Add decorator to handle backup errors

* Bump version

* Missed one

* Add unauth handling

* Apply suggestions from code review

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/webdav/__init__.py

* Update homeassistant/components/webdav/config_flow.py

* Remove timeout

Co-authored-by: Josef Zweck <josef@zweck.dev>

* remove unique_id

* Add tests

* Add missing tests

* Bump version

* Remove dropbox

* Process code review

* Bump version to relax pinned dependencies

* Process code review

* Add translatable exceptions

* Process code review

* Process code review

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
pull/139200/head
Jan-Philipp Benecke 2025-02-24 18:00:48 +01:00 committed by GitHub
parent 2e5f56b70d
commit ec3f5561dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1302 additions and 0 deletions

2
CODEOWNERS generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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."
}
}
}

View File

@ -692,6 +692,7 @@ FLOWS = {
"weatherflow",
"weatherflow_cloud",
"weatherkit",
"webdav",
"webmin",
"webostv",
"weheat",

View File

@ -7092,6 +7092,12 @@
}
}
},
"webdav": {
"name": "WebDAV",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"webmin": {
"name": "Webmin",
"integration_type": "device",

3
requirements_all.txt generated
View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the WebDAV integration."""

View File

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

View File

@ -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",
)

View File

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

View File

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