diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 10294f6ff12..ce3fea80f67 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -53,6 +53,7 @@ __all__ = [ "NewBackup", "RestoreBackupEvent", "WrittenBackup", + "async_get_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b909b2728a7..3d3877cc2f7 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView): class UploadBackupView(HomeAssistantView): - """Generate backup view.""" + """Upload backup view.""" url = "/api/backup/upload" name = "api:backup:upload" @require_admin async def post(self, request: Request) -> Response: + """Upload a backup file.""" + return await self._post(request) + + async def _post(self, request: Request) -> Response: """Upload a backup file.""" try: agent_ids = request.query.getall("agent_id") @@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView): contents = cast(BodyPartReader, await reader.next()) try: - await manager.async_receive_backup(contents=contents, agent_ids=agent_ids) + backup_id = await manager.async_receive_backup( + contents=contents, agent_ids=agent_ids + ) except OSError as err: return Response( body=f"Can't write backup file: {err}", @@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView): except asyncio.CancelledError: return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return Response(status=HTTPStatus.CREATED) + return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4a871cdf73e..19ebb8011ee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -298,6 +298,7 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() + self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] async def async_setup(self) -> None: @@ -620,7 +621,7 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" if self.state is not BackupManagerState.IDLE: raise BackupManagerError(f"Backup manager busy: {self.state}") @@ -632,7 +633,9 @@ class BackupManager: ) ) try: - await self._async_receive_backup(agent_ids=agent_ids, contents=contents) + backup_id = await self._async_receive_backup( + agent_ids=agent_ids, contents=contents + ) except Exception: self.async_on_backup_event( ReceiveBackupEvent( @@ -650,6 +653,7 @@ class BackupManager: state=ReceiveBackupState.COMPLETED, ) ) + return backup_id finally: self.async_on_backup_event(IdleEvent()) @@ -658,7 +662,7 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" contents.chunk_size = BUF_SIZE self.async_on_backup_event( @@ -687,6 +691,7 @@ class BackupManager: ) await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) + return written_backup.backup.backup_id async def async_create_backup( self, @@ -1041,6 +1046,8 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event + if not isinstance(event, IdleEvent): + self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 918d845993a..8e253d4bff9 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["hassio"], + "after_dependencies": ["backup", "hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b33440a9eb7..edf0b615779 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -15,10 +16,18 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth +from homeassistant.components.backup import ( + BackupManager, + Folder, + IncorrectPasswordError, + async_get_manager as async_get_backup_manager, + http as backup_http, +) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info @@ -50,6 +59,9 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) class OnboardingView(HomeAssistantView): @@ -312,6 +324,119 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) +class BackupOnboardingView(HomeAssistantView): + """Backup onboarding view.""" + + requires_auth = False + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the view.""" + self._data = data + + +def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"error": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(BackupOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": [backup.as_frontend_json() for backup in backups.values()], + "state": manager.state, + "last_non_idle_event": manager.last_non_idle_event, + } + ) + + +class RestoreBackupView(BackupOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index b7b86cc1d45..ee6803655d5 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -233,12 +233,14 @@ async def test_uploading_a_backup_file( with patch( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value=TEST_BACKUP_ABC123.backup_id, ) as async_receive_backup_mock: resp = await client.post( "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 + assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id} assert async_receive_backup_mock.called diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr new file mode 100644 index 00000000000..90428055823 --- /dev/null +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_onboarding_backup_info + dict({ + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_automatic_settings': None, + }), + ]), + 'last_non_idle_event': None, + 'state': 'idle', + }) +# --- diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 35f6b7d739c..683d2c370f2 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,13 +3,15 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus +from io import StringIO import os from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest +from syrupy import SnapshotAssertion -from homeassistant.components import onboarding +from homeassistant.components import backup, onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar @@ -649,12 +651,28 @@ async def test_onboarding_installation_type( assert resp_content["installation_type"] == "Home Assistant Core" -async def test_onboarding_installation_type_after_done( +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "installation_type", {}), + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], ) -> None: - """Test raising for installation type after onboarding.""" + """Test raising after onboarding.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) @@ -662,7 +680,7 @@ async def test_onboarding_installation_type_after_done( client = await hass_client() - resp = await client.get("/api/onboarding/installation_type") + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) assert resp.status == 401 @@ -726,3 +744,286 @@ async def test_complete_onboarding( listener_3 = Mock() onboarding.async_add_listener(hass, listener_3) listener_3.assert_called_once_with() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 500 + assert await resp.json() == {"error": "backup_disabled"} + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, + agent_ids=["backup.local"], + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + protected=False, + size=1, + agent_ids=["test.remote"], + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + "Message format incorrect: required key not provided @ data['agent_id']", + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + "Message format incorrect: required key not provided @ data['backup_id']", + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + "Message format incorrect: expected bool for dictionary value @ data['restore_database']", + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]", + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + "incorrect_password", + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == {"message": expected_message} + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY)