mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 08:26:41 +01:00
503 lines
16 KiB
Python
503 lines
16 KiB
Python
"""Test the backups for WebDAV."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
from copy import deepcopy
|
|
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.helpers.json import json_dumps
|
|
from homeassistant.setup import async_setup_component
|
|
|
|
from .const import BACKUP_METADATA
|
|
|
|
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",
|
|
"database_included": True,
|
|
"date": "2025-02-10T17:47:22.727189+01:00",
|
|
"extra_metadata": {},
|
|
"failed_addons": [],
|
|
"failed_agent_ids": [],
|
|
"failed_folders": [],
|
|
"folders": [],
|
|
"homeassistant_included": True,
|
|
"homeassistant_version": "2025.2.1",
|
|
"name": "Automatic backup 2025.2.1",
|
|
"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",
|
|
"database_included": True,
|
|
"date": "2025-02-10T17:47:22.727189+01:00",
|
|
"extra_metadata": {},
|
|
"failed_addons": [],
|
|
"failed_agent_ids": [],
|
|
"failed_folders": [],
|
|
"folders": [],
|
|
"homeassistant_included": True,
|
|
"homeassistant_version": "2025.2.1",
|
|
"name": "Automatic backup 2025.2.1",
|
|
"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
|
|
|
|
|
|
async def test_agents_upload_emits_progress_events(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_client: ClientSessionGenerator,
|
|
webdav_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test upload emits progress events with bytes from upload_iter callbacks."""
|
|
test_backup = AgentBackup.from_dict(BACKUP_METADATA)
|
|
client = await hass_client()
|
|
ws_client = await hass_ws_client(hass)
|
|
observed_progress_bytes: list[int] = []
|
|
|
|
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
|
|
response = await ws_client.receive_json()
|
|
assert response["event"] == {"manager_state": "idle"}
|
|
response = await ws_client.receive_json()
|
|
assert response["success"] is True
|
|
|
|
async def _mock_upload_iter(*args: object, **kwargs: object) -> None:
|
|
"""Mock upload and trigger progress callback for backup upload."""
|
|
path = args[1]
|
|
if path.endswith(".tar"):
|
|
progress = kwargs.get("progress")
|
|
assert callable(progress)
|
|
progress(1024, test_backup.size)
|
|
progress(test_backup.size, test_backup.size)
|
|
|
|
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""])
|
|
webdav_client.upload_iter.side_effect = _mock_upload_iter
|
|
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")},
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert resp.status == 201
|
|
|
|
# Gather progress events from the upload flow.
|
|
reached_idle = False
|
|
for _ in range(20):
|
|
response = await ws_client.receive_json()
|
|
event = response.get("event")
|
|
|
|
if event is None:
|
|
continue
|
|
|
|
if (
|
|
event.get("manager_state") == "receive_backup"
|
|
and event.get("agent_id") == f"{DOMAIN}.{mock_config_entry.entry_id}"
|
|
and "uploaded_bytes" in event
|
|
):
|
|
observed_progress_bytes.append(event["uploaded_bytes"])
|
|
|
|
if event == {"manager_state": "idle"}:
|
|
reached_idle = True
|
|
break
|
|
|
|
assert reached_idle
|
|
assert 1024 in observed_progress_bytes
|
|
assert test_backup.size in observed_progress_bytes
|
|
|
|
|
|
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_files.return_value = []
|
|
|
|
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_files.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_files.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_files.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
|
|
|
|
|
|
async def test_agents_list_backups_with_multi_chunk_metadata(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
webdav_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test listing backups when metadata is returned in multiple chunks."""
|
|
metadata_json = json_dumps(BACKUP_METADATA).encode()
|
|
mid = len(metadata_json) // 2
|
|
chunk1 = metadata_json[:mid]
|
|
chunk2 = metadata_json[mid:]
|
|
|
|
async def _multi_chunk_download(path: str, timeout=None) -> AsyncIterator[bytes]:
|
|
"""Mock download returning metadata in multiple chunks."""
|
|
if path.endswith(".json"):
|
|
yield chunk1
|
|
yield chunk2
|
|
return
|
|
yield b"backup data"
|
|
|
|
webdav_client.download_iter.side_effect = _multi_chunk_download
|
|
|
|
# Invalidate the metadata cache so the new mock is used
|
|
hass.config_entries.async_update_entry(
|
|
mock_config_entry, title=mock_config_entry.title
|
|
)
|
|
await hass.config_entries.async_reload(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
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"] == {}
|
|
backups = response["result"]["backups"]
|
|
assert len(backups) == 1
|
|
assert backups[0]["backup_id"] == BACKUP_METADATA["backup_id"]
|
|
assert backups[0]["name"] == BACKUP_METADATA["name"]
|
|
|
|
|
|
async def test_agents_list_backups_skips_invalid_metadata_file(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
webdav_client: AsyncMock,
|
|
) -> None:
|
|
"""Test listing backups skips unreadable metadata files."""
|
|
broken_metadata_path = "/broken.metadata.json"
|
|
valid_metadata_path = "/valid.metadata.json"
|
|
valid_backup = deepcopy(BACKUP_METADATA)
|
|
valid_backup["backup_id"] = "valid-backup"
|
|
valid_backup["name"] = "Valid backup"
|
|
|
|
webdav_client.list_files.return_value = [broken_metadata_path, valid_metadata_path]
|
|
|
|
async def _download_metadata(path: str, timeout=None) -> AsyncIterator[bytes]:
|
|
"""Mock metadata downloads with one broken and one valid file."""
|
|
if path == broken_metadata_path:
|
|
yield b""
|
|
return
|
|
|
|
if path == valid_metadata_path:
|
|
yield json_dumps(valid_backup).encode()
|
|
return
|
|
|
|
yield b"backup data"
|
|
|
|
webdav_client.download_iter.side_effect = _download_metadata
|
|
|
|
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": "valid-backup",
|
|
"database_included": True,
|
|
"date": "2025-02-10T17:47:22.727189+01:00",
|
|
"extra_metadata": {},
|
|
"failed_addons": [],
|
|
"failed_agent_ids": [],
|
|
"failed_folders": [],
|
|
"folders": [],
|
|
"homeassistant_included": True,
|
|
"homeassistant_version": "2025.2.1",
|
|
"name": "Valid backup",
|
|
"with_automatic_settings": None,
|
|
}
|
|
]
|