From 0ed51dae13f08359e0b322740ab892b1a700b20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 1 Mar 2022 00:48:12 +0100 Subject: [PATCH] Add Backup integration (#66395) Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 2 + homeassistant/components/backup/__init__.py | 26 +++ homeassistant/components/backup/const.py | 15 ++ homeassistant/components/backup/http.py | 49 +++++ homeassistant/components/backup/manager.py | 173 ++++++++++++++++++ homeassistant/components/backup/manifest.json | 17 ++ homeassistant/components/backup/websocket.py | 69 +++++++ .../components/default_config/__init__.py | 4 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/backup/__init__.py | 1 + tests/components/backup/common.py | 29 +++ tests/components/backup/test_http.py | 60 ++++++ tests/components/backup/test_init.py | 18 ++ tests/components/backup/test_manager.py | 153 ++++++++++++++++ tests/components/backup/test_websocket.py | 83 +++++++++ 16 files changed, 705 insertions(+) create mode 100644 homeassistant/components/backup/__init__.py create mode 100644 homeassistant/components/backup/const.py create mode 100644 homeassistant/components/backup/http.py create mode 100644 homeassistant/components/backup/manager.py create mode 100644 homeassistant/components/backup/manifest.json create mode 100644 homeassistant/components/backup/websocket.py create mode 100644 tests/components/backup/__init__.py create mode 100644 tests/components/backup/common.py create mode 100644 tests/components/backup/test_http.py create mode 100644 tests/components/backup/test_init.py create mode 100644 tests/components/backup/test_manager.py create mode 100644 tests/components/backup/test_websocket.py diff --git a/CODEOWNERS b/CODEOWNERS index 20ecc0272cf..ce1e69244d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -111,6 +111,8 @@ tests/components/azure_devops/* @timmo001 homeassistant/components/azure_event_hub/* @eavanvalkenburg tests/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_service_bus/* @hfurubotten +homeassistant/components/backup/* @home-assistant/core +tests/components/backup/* @home-assistant/core homeassistant/components/balboa/* @garbled1 tests/components/balboa/* @garbled1 homeassistant/components/beewi_smartclim/* @alemuro diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py new file mode 100644 index 00000000000..711051507d4 --- /dev/null +++ b/homeassistant/components/backup/__init__.py @@ -0,0 +1,26 @@ +"""The Backup integration.""" +from homeassistant.components.hassio import is_hassio +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, LOGGER +from .http import async_register_http_views +from .manager import BackupManager +from .websocket import async_register_websocket_handlers + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Backup integration.""" + if is_hassio(hass): + LOGGER.error( + "The backup integration is not supported on this installation method, " + "please remove it from your configuration" + ) + return False + + hass.data[DOMAIN] = BackupManager(hass) + + async_register_websocket_handlers(hass) + async_register_http_views(hass) + + return True diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py new file mode 100644 index 00000000000..a4a08fff75d --- /dev/null +++ b/homeassistant/components/backup/const.py @@ -0,0 +1,15 @@ +"""Constants for the Backup integration.""" +from logging import getLogger + +DOMAIN = "backup" +LOGGER = getLogger(__package__) + +EXCLUDE_FROM_BACKUP = [ + "__pycache__/*", + ".DS_Store", + "*.db-shm", + "*.log.*", + "*.log", + "backups/*.tar", + "OZW_Log.txt", +] diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py new file mode 100644 index 00000000000..6f1f0d00167 --- /dev/null +++ b/homeassistant/components/backup/http.py @@ -0,0 +1,49 @@ +"""Http view for the Backup integration.""" +from __future__ import annotations + +from http import HTTPStatus + +from aiohttp.hdrs import CONTENT_DISPOSITION +from aiohttp.web import FileResponse, Request, Response + +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .const import DOMAIN +from .manager import BackupManager + + +@callback +def async_register_http_views(hass: HomeAssistant) -> None: + """Register the http views.""" + hass.http.register_view(DownloadBackupView) + + +class DownloadBackupView(HomeAssistantView): + """Generate backup view.""" + + url = "/api/backup/download/{slug}" + name = "api:backup:download" + + async def get( # pylint: disable=no-self-use + self, + request: Request, + slug: str, + ) -> FileResponse | Response: + """Download a backup file.""" + if not request["hass_user"].is_admin: + return Response(status=HTTPStatus.UNAUTHORIZED) + + manager: BackupManager = request.app["hass"].data[DOMAIN] + backup = await manager.get_backup(slug) + + if backup is None or not backup.path.exists(): + return Response(status=HTTPStatus.NOT_FOUND) + + return FileResponse( + path=backup.path.as_posix(), + headers={ + CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" + }, + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py new file mode 100644 index 00000000000..5861db1ad27 --- /dev/null +++ b/homeassistant/components/backup/manager.py @@ -0,0 +1,173 @@ +"""Backup manager for the Backup integration.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import json +from pathlib import Path +from tarfile import TarError +from tempfile import TemporaryDirectory + +from securetar import SecureTarFile, atomic_contents_add + +from homeassistant.const import __version__ as HAVERSION +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt, json as json_util + +from .const import EXCLUDE_FROM_BACKUP, LOGGER + + +@dataclass +class Backup: + """Backup class.""" + + slug: str + name: str + date: str + path: Path + size: float + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return {**asdict(self), "path": self.path.as_posix()} + + +class BackupManager: + """Backup manager for the Backup integration.""" + + _backups: dict[str, Backup] = {} + _loaded = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup manager.""" + self.hass = hass + self.backup_dir = Path(hass.config.path("backups")) + self.backing_up = False + + async def load_backups(self) -> None: + """Load data of stored backup files.""" + backups = {} + + def _read_backups() -> None: + for backup_path in self.backup_dir.glob("*.tar"): + try: + with SecureTarFile(backup_path, "r", gzip=False) as backup_file: + if data_file := backup_file.extractfile("./backup.json"): + data = json.loads(data_file.read()) + backup = Backup( + slug=data["slug"], + name=data["name"], + date=data["date"], + path=backup_path, + size=round(backup_path.stat().st_size / 1_048_576, 2), + ) + backups[backup.slug] = backup + except (OSError, TarError, json.JSONDecodeError) as err: + LOGGER.warning("Unable to read backup %s: %s", backup_path, err) + + await self.hass.async_add_executor_job(_read_backups) + LOGGER.debug("Loaded %s backups", len(backups)) + self._backups = backups + self._loaded = True + + async def get_backups(self) -> dict[str, Backup]: + """Return backups.""" + if not self._loaded: + await self.load_backups() + + return self._backups + + async def get_backup(self, slug: str) -> Backup | None: + """Return a backup.""" + if not self._loaded: + await self.load_backups() + + if not (backup := self._backups.get(slug)): + return None + + if not backup.path.exists(): + LOGGER.debug( + "Removing tracked backup (%s) that does not exists on the expected path %s", + backup.slug, + backup.path, + ) + self._backups.pop(slug) + return None + + return backup + + async def remove_backup(self, slug: str) -> None: + """Remove a backup.""" + if (backup := await self.get_backup(slug)) is None: + return + + await self.hass.async_add_executor_job(backup.path.unlink, True) + LOGGER.debug("Removed backup located at %s", backup.path) + self._backups.pop(slug) + + async def generate_backup(self) -> Backup: + """Generate a backup.""" + if self.backing_up: + raise HomeAssistantError("Backup already in progress") + + try: + self.backing_up = True + backup_name = f"Core {HAVERSION}" + date_str = dt.now().isoformat() + slug = _generate_slug(date_str, backup_name) + + backup_data = { + "slug": slug, + "name": backup_name, + "date": date_str, + "type": "partial", + "folders": ["homeassistant"], + "homeassistant": {"version": HAVERSION}, + "compressed": True, + } + tar_file_path = Path(self.backup_dir, f"{slug}.tar") + + if not self.backup_dir.exists(): + LOGGER.debug("Creating backup directory") + self.hass.async_add_executor_job(self.backup_dir.mkdir) + + def _create_backup() -> None: + with TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + json_util.save_json( + tmp_dir_path.joinpath("./backup.json").as_posix(), + backup_data, + ) + with SecureTarFile(tar_file_path, "w", gzip=False) as tar_file: + with SecureTarFile( + tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), + "w", + ) as core_tar: + atomic_contents_add( + tar_file=core_tar, + origin_path=Path(self.hass.config.path()), + excludes=EXCLUDE_FROM_BACKUP, + arcname="data", + ) + tar_file.add(tmp_dir_path, arcname=".") + + await self.hass.async_add_executor_job(_create_backup) + backup = Backup( + slug=slug, + name=backup_name, + date=date_str, + path=tar_file_path, + size=round(tar_file_path.stat().st_size / 1_048_576, 2), + ) + if self._loaded: + self._backups[slug] = backup + LOGGER.debug("Generated new backup with slug %s", slug) + return backup + finally: + self.backing_up = False + + +def _generate_slug(date: str, name: str) -> str: + """Generate a backup slug.""" + return hashlib.sha1(f"{date} - {name}".lower().encode()).hexdigest()[:8] diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json new file mode 100644 index 00000000000..9ae12c4a9d7 --- /dev/null +++ b/homeassistant/components/backup/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "backup", + "name": "Backup", + "documentation": "https://www.home-assistant.io/integrations/backup", + "dependencies": [ + "http", + "websocket_api" + ], + "codeowners": [ + "@home-assistant/core" + ], + "requirements": [ + "securetar==2022.2.0" + ], + "iot_class": "calculated", + "quality_scale": "internal" +} \ No newline at end of file diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py new file mode 100644 index 00000000000..2f1381ce217 --- /dev/null +++ b/homeassistant/components/backup/websocket.py @@ -0,0 +1,69 @@ +"""Websocket commands for the Backup integration.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .manager import BackupManager + + +@callback +def async_register_websocket_handlers(hass: HomeAssistant) -> None: + """Register websocket commands.""" + websocket_api.async_register_command(hass, handle_info) + websocket_api.async_register_command(hass, handle_create) + websocket_api.async_register_command(hass, handle_remove) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/info"}) +@websocket_api.async_response +async def handle_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +): + """List all stored backups.""" + manager: BackupManager = hass.data[DOMAIN] + backups = await manager.get_backups() + connection.send_result( + msg["id"], + { + "backups": list(backups), + "backing_up": manager.backing_up, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/remove", + vol.Required("slug"): str, + } +) +@websocket_api.async_response +async def handle_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +): + """Remove a backup.""" + manager: BackupManager = hass.data[DOMAIN] + await manager.remove_backup(msg["slug"]) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/generate"}) +@websocket_api.async_response +async def handle_create( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +): + """Generate a backup.""" + manager: BackupManager = hass.data[DOMAIN] + backup = await manager.generate_backup() + connection.send_result(msg["id"], backup) diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 9e6ab268172..574d97c6d29 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -5,6 +5,7 @@ try: except ImportError: av = None +from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -14,6 +15,9 @@ DOMAIN = "default_config" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize default configuration.""" + if not is_hassio(hass): + await async_setup_component(hass, "backup", config) + if av is None: return True diff --git a/requirements_all.txt b/requirements_all.txt index 7a05d33c86c..1ed574664f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2110,6 +2110,9 @@ screenlogicpy==0.5.4 # homeassistant.components.scsgate scsgate==0.1.0 +# homeassistant.components.backup +securetar==2022.2.0 + # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 188a56c2aa4..4a406675913 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1338,6 +1338,9 @@ scapy==2.4.5 # homeassistant.components.screenlogic screenlogicpy==0.5.4 +# homeassistant.components.backup +securetar==2022.2.0 + # homeassistant.components.emulated_kasa # homeassistant.components.sense sense_energy==0.10.2 diff --git a/tests/components/backup/__init__.py b/tests/components/backup/__init__.py new file mode 100644 index 00000000000..0c5dcea461a --- /dev/null +++ b/tests/components/backup/__init__.py @@ -0,0 +1 @@ +"""Tests for the Backup integration.""" diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py new file mode 100644 index 00000000000..824057b6500 --- /dev/null +++ b/tests/components/backup/common.py @@ -0,0 +1,29 @@ +"""Common helpers for the Backup integration tests.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup.manager import Backup +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +TEST_BACKUP = Backup( + slug="abc123", + name="Test", + date="1970-01-01T00:00:00.000Z", + path=Path("abc123.tar"), + size=0.0, +) + + +async def setup_backup_integration( + hass: HomeAssistant, + with_hassio: bool = False, + configuration: ConfigType | None = None, +) -> bool: + """Set up the Backup integration.""" + with patch("homeassistant.components.backup.is_hassio", return_value=with_hassio): + return await async_setup_component(hass, DOMAIN, configuration or {}) diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py new file mode 100644 index 00000000000..708abec057b --- /dev/null +++ b/tests/components/backup/test_http.py @@ -0,0 +1,60 @@ +"""Tests for the Backup integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from aiohttp import ClientSession, web + +from homeassistant.core import HomeAssistant + +from .common import TEST_BACKUP, setup_backup_integration + +from tests.common import MockUser + + +async def test_downloading_backup( + hass: HomeAssistant, + hass_client: Callable[..., Awaitable[ClientSession]], +) -> None: + """Test downloading a backup file.""" + await setup_backup_integration(hass) + + client = await hass_client() + + with patch( + "homeassistant.components.backup.http.BackupManager.get_backup", + return_value=TEST_BACKUP, + ), patch("pathlib.Path.exists", return_value=True), patch( + "homeassistant.components.backup.http.FileResponse", + return_value=web.Response(text=""), + ): + + resp = await client.get("/api/backup/download/abc123") + assert resp.status == 200 + + +async def test_downloading_backup_not_found( + hass: HomeAssistant, + hass_client: Callable[..., Awaitable[ClientSession]], +) -> None: + """Test downloading a backup file that does not exist.""" + await setup_backup_integration(hass) + + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123") + assert resp.status == 404 + + +async def test_non_admin( + hass: HomeAssistant, + hass_client: Callable[..., Awaitable[ClientSession]], + hass_admin_user: MockUser, +) -> None: + """Test downloading a backup file that does not exist.""" + hass_admin_user.groups = [] + await setup_backup_integration(hass) + + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123") + assert resp.status == 401 diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py new file mode 100644 index 00000000000..a9e2fe20c6b --- /dev/null +++ b/tests/components/backup/test_init.py @@ -0,0 +1,18 @@ +"""Tests for the Backup integration.""" +import pytest + +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + + +async def test_setup_with_hassio( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setup of the integration with hassio enabled.""" + assert not await setup_backup_integration(hass=hass, with_hassio=True) + assert ( + "The backup integration is not supported on this installation method, please remove it from your configuration" + in caplog.text + ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py new file mode 100644 index 00000000000..0c4b21746f0 --- /dev/null +++ b/tests/components/backup/test_manager.py @@ -0,0 +1,153 @@ +"""Tests for the Backup integration.""" +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.backup import BackupManager +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import TEST_BACKUP + + +async def test_constructor(hass: HomeAssistant) -> None: + """Test BackupManager constructor.""" + manager = BackupManager(hass) + assert manager.backup_dir.as_posix() == hass.config.path("backups") + + +async def test_load_backups(hass: HomeAssistant) -> None: + """Test loading backups.""" + manager = BackupManager(hass) + with patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), patch( + "tarfile.open", return_value=MagicMock() + ), patch( + "json.loads", + return_value={ + "slug": TEST_BACKUP.slug, + "name": TEST_BACKUP.name, + "date": TEST_BACKUP.date, + }, + ), patch( + "pathlib.Path.stat", return_value=MagicMock(st_size=TEST_BACKUP.size) + ): + await manager.load_backups() + backups = await manager.get_backups() + assert backups == {TEST_BACKUP.slug: TEST_BACKUP} + + +async def test_load_backups_with_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading backups with exception.""" + manager = BackupManager(hass) + with patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), patch( + "tarfile.open", side_effect=OSError("Test ecxeption") + ): + await manager.load_backups() + backups = await manager.get_backups() + assert f"Unable to read backup {TEST_BACKUP.path}: Test ecxeption" in caplog.text + assert backups == {} + + +async def test_removing_non_existing_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removing not existing backup.""" + manager = BackupManager(hass) + + await manager.remove_backup("non_existing") + assert "Removed backup located at" not in caplog.text + + +async def test_getting_backup_that_does_not_exist( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +): + """Test getting backup that does not exist.""" + manager = BackupManager(hass) + + with patch( + "homeassistant.components.backup.websocket.BackupManager._backups", + {TEST_BACKUP.slug: TEST_BACKUP}, + ), patch( + "homeassistant.components.backup.websocket.BackupManager._loaded", + True, + ), patch( + "pathlib.Path.exists", return_value=False + ): + backup = await manager.get_backup(TEST_BACKUP.slug) + assert backup is None + + assert ( + f"Removing tracked backup ({TEST_BACKUP.slug}) that " + f"does not exists on the expected path {TEST_BACKUP.path}" in caplog.text + ) + + +async def test_generate_backup_when_backing_up(hass: HomeAssistant) -> None: + """Test generate backup.""" + manager = BackupManager(hass) + manager.backing_up = True + with pytest.raises(HomeAssistantError, match="Backup already in progress"): + await manager.generate_backup() + + +async def test_generate_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test generate backup.""" + manager = BackupManager(hass) + + def _mock_iterdir(path: Path) -> list[Path]: + if not path.name.endswith("testing_config"): + return [] + return [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + ] + + with patch("tarfile.open", MagicMock()) as mocked_tarfile, patch( + "pathlib.Path.iterdir", _mock_iterdir + ), patch("pathlib.Path.stat", MagicMock(st_size=123)), patch( + "pathlib.Path.is_file", lambda x: x.name != ".storage" + ), patch( + "pathlib.Path.is_dir", + lambda x: x.name == ".storage", + ), patch( + "pathlib.Path.exists", + lambda x: x != manager.backup_dir, + ), patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), patch( + "pathlib.Path.mkdir", + MagicMock(), + ), patch( + "homeassistant.components.backup.manager.json_util.save_json" + ) as mocked_json_util, patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), patch( + "homeassistant.components.backup.websocket.BackupManager._loaded", + True, + ): + await manager.generate_backup() + + assert mocked_json_util.call_count == 1 + assert mocked_json_util.call_args[0][1]["homeassistant"] == { + "version": "2025.1.0" + } + + assert ( + manager.backup_dir.as_posix() + in mocked_tarfile.call_args_list[0].kwargs["name"] + ) + + assert "Generated new backup with slug " in caplog.text + assert "Creating backup directory" in caplog.text diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py new file mode 100644 index 00000000000..7c2f9a8c752 --- /dev/null +++ b/tests/components/backup/test_websocket.py @@ -0,0 +1,83 @@ +"""Tests for the Backup integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from aiohttp import ClientWebSocketResponse +import pytest + +from homeassistant.core import HomeAssistant + +from .common import TEST_BACKUP, setup_backup_integration + + +async def test_info( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test getting backup info.""" + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json({"id": 1, "type": "backup/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"backing_up": False, "backups": []} + + +async def test_remove( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removing a backup file.""" + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.websocket.BackupManager._backups", + {TEST_BACKUP.slug: TEST_BACKUP}, + ), patch( + "homeassistant.components.backup.websocket.BackupManager._loaded", + True, + ), patch( + "pathlib.Path.unlink" + ), patch( + "pathlib.Path.exists", return_value=True + ): + await client.send_json({"id": 1, "type": "backup/remove", "slug": "abc123"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert f"Removed backup located at {TEST_BACKUP.path}" in caplog.text + + +async def test_generate( + hass: HomeAssistant, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test removing a backup file.""" + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.websocket.BackupManager._backups", + {TEST_BACKUP.slug: TEST_BACKUP}, + ), patch( + "homeassistant.components.backup.websocket.BackupManager.generate_backup", + return_value=TEST_BACKUP, + ): + await client.send_json({"id": 1, "type": "backup/generate"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == TEST_BACKUP.as_dict()