"""Test the cloud backup platform.""" from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.files import FilesError import pytest from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, AddonInfo, AgentBackup, Folder, ) from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator class MockStreamReaderChunked(MockStreamReader): """Mock a stream reader with simulated chunked data.""" async def readchunk(self) -> tuple[bytes, bool]: """Read bytes.""" return (self._content.read(), False) @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: """Set up cloud 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, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() yield @pytest.fixture def mock_delete_file() -> Generator[MagicMock]: """Mock list files.""" with patch( "homeassistant.components.cloud.backup.async_files_delete_file", spec_set=True, ) as delete_file: yield delete_file @pytest.fixture def mock_list_files() -> Generator[MagicMock]: """Mock list files.""" with patch( "homeassistant.components.cloud.backup.async_files_list", spec_set=True ) as list_files: list_files.return_value = [ { "Key": "462e16810d6841228828d9dd2f9e341e.tar", "LastModified": "2024-11-22T10:49:01.182Z", "Size": 34519040, "Metadata": { "addons": [], "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", "protected": False, "size": 34519040, "storage-type": "backup", }, } ] yield list_files @pytest.fixture def cloud_logged_in(cloud: MagicMock): """Mock cloud logged in.""" type(cloud).is_logged_in = PropertyMock(return_value=True) async def test_agents_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> 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": "cloud.cloud", "name": "cloud"}, ], } async def test_agents_list_backups( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, cloud: MagicMock, mock_list_files: Mock, ) -> 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() mock_list_files.assert_called_once_with(cloud, storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} assert response["result"]["backups"] == [ { "addons": [], "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", "failed_agent_ids": [], "with_automatic_settings": None, } ] @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) async def test_agents_list_backups_fail_cloud( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, cloud: MagicMock, mock_list_files: Mock, side_effect: Exception, ) -> None: """Test agent list backups.""" client = await hass_ws_client(hass) mock_list_files.side_effect = side_effect await client.send_json_auto_id({"type": "backup/info"}) response = await client.receive_json() assert response["success"] assert response["result"] == { "agent_errors": {"cloud.cloud": "Failed to list backups"}, "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", } @pytest.mark.parametrize( ("backup_id", "expected_result"), [ ( "23e64aec", { "addons": [], "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", "failed_agent_ids": [], "with_automatic_settings": None, }, ), ( "12345", None, ), ], ids=["found", "not_found"], ) async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, cloud: MagicMock, backup_id: str, expected_result: dict[str, Any] | None, mock_list_files: Mock, ) -> None: """Test agent get backup.""" client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) response = await client.receive_json() mock_list_files.assert_called_once_with(cloud, storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} assert response["result"]["backup"] == expected_result @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, cloud: Mock, ) -> None: """Test agent download backup.""" client = await hass_client() backup_id = "23e64aec" cloud.files.download.return_value = MockStreamReaderChunked(b"backup data") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_fail_get( hass: HomeAssistant, hass_client: ClientSessionGenerator, cloud: Mock, ) -> None: """Test agent download backup, when cloud user is logged in.""" client = await hass_client() backup_id = "23e64aec" cloud.files.download.side_effect = FilesError("Oh no :(") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 500 content = await resp.content.read() assert "Failed to download backup" in content.decode() @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> None: """Test agent download backup raises error if not found.""" client = await hass_client() backup_id = "1234" resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 404 assert await resp.content.read() == b"" @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, cloud: Mock, ) -> None: """Test agent upload backup.""" client = await hass_client() backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, database_included=True, date="1970-01-01T00:00:00.000Z", extra_metadata={}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", protected=True, size=len(backup_data), ) 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=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO(backup_data)}, ) assert len(cloud.files.upload.mock_calls) == 1 metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] assert metadata["backup_id"] == backup_id assert resp.status == 201 assert f"Uploading backup {backup_id}" in caplog.text @pytest.mark.parametrize("side_effect", [FilesError("Boom!"), CloudError("Boom!")]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], side_effect: Exception, cloud: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" client = await hass_client() backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, database_included=True, date="1970-01-01T00:00:00.000Z", extra_metadata={}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", protected=True, size=len(backup_data), ) cloud.files.upload.side_effect = side_effect 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, patch("homeassistant.components.cloud.backup.asyncio.sleep"), patch("homeassistant.components.cloud.backup.random.randint", return_value=60), patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 assert cloud.files.upload.call_count == 2 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] assert stored_backup["backup_id"] == backup_id assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] @pytest.mark.parametrize( ("side_effect", "logmsg"), [ ( CloudApiNonRetryableError("Boom!", code="NC-SH-FH-03"), "The backup size of 13.37GB is too large to be uploaded to Home Assistant Cloud", ), ( CloudApiNonRetryableError("Boom!", code="NC-CE-01"), "Failed to upload backup Boom!", ), ], ) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_upload_fail_non_retryable( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], side_effect: Exception, logmsg: str, cloud: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails with non-retryable error.""" client = await hass_client() backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, database_included=True, date="1970-01-01T00:00:00.000Z", extra_metadata={}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", protected=True, size=14358124749, ) cloud.files.upload.side_effect = side_effect 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, patch("homeassistant.components.cloud.backup.calculate_b64md5"), ): mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() assert logmsg in caplog.text assert resp.status == 201 assert cloud.files.upload.call_count == 1 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] assert stored_backup["backup_id"] == backup_id assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] async def test_agents_upload_not_protected( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, database_included=True, date="1970-01-01T00:00:00.000Z", extra_metadata={}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", protected=False, size=len(backup_data), ) with ( patch("pathlib.Path.open"), patch( "homeassistant.components.backup.manager.read_backup", return_value=test_backup, ), ): resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] assert stored_backup["backup_id"] == backup_id assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_upload_wrong_size( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, cloud: Mock, ) -> None: """Test agent upload backup with the wrong size.""" client = await hass_client() backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, database_included=True, date="1970-01-01T00:00:00.000Z", extra_metadata={}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", protected=True, size=len(backup_data) - 1, ) 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=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", data={"file": StringIO(backup_data)}, ) assert len(cloud.files.upload.mock_calls) == 0 assert resp.status == 201 assert "Upload failed for cloud.cloud" in caplog.text @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_delete_file: Mock, ) -> None: """Test agent delete backup.""" client = await hass_ws_client(hass) backup_id = "23e64aec" await client.send_json_auto_id( { "type": "backup/delete", "backup_id": backup_id, } ) response = await client.receive_json() assert response["success"] assert response["result"] == {"agent_errors": {}} mock_delete_file.assert_called_once() @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete_fail_cloud( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_delete_file: Mock, side_effect: Exception, ) -> None: """Test agent delete backup.""" client = await hass_ws_client(hass) backup_id = "23e64aec" mock_delete_file.side_effect = side_effect await client.send_json_auto_id( { "type": "backup/delete", "backup_id": backup_id, } ) response = await client.receive_json() assert response["success"] assert response["result"] == { "agent_errors": {"cloud.cloud": "Failed to delete backup"} } @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test agent download backup raises error if not found.""" client = await hass_ws_client(hass) backup_id = "1234" await client.send_json_auto_id( { "type": "backup/delete", "backup_id": backup_id, } ) response = await client.receive_json() assert response["success"] assert response["result"] == {"agent_errors": {}} @pytest.mark.parametrize("event_type", ["login", "logout"]) async def test_calling_listener_on_login_logout( hass: HomeAssistant, event_type: str, ) -> None: """Test calling listener for login and logout events.""" listener = MagicMock() async_register_backup_agents_listener(hass, listener=listener) assert listener.call_count == 0 async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": event_type}) await hass.async_block_till_done() assert listener.call_count == 1 async def test_not_calling_listener_after_unsub(hass: HomeAssistant) -> None: """Test only calling listener until unsub.""" listener = MagicMock() unsub = async_register_backup_agents_listener(hass, listener=listener) assert listener.call_count == 0 async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"}) await hass.async_block_till_done() assert listener.call_count == 1 unsub() async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"}) await hass.async_block_till_done() assert listener.call_count == 1 async def test_not_calling_listener_with_unknown_event_type( hass: HomeAssistant, ) -> None: """Test not calling listener if we did not get the expected event type.""" listener = MagicMock() async_register_backup_agents_listener(hass, listener=listener) assert listener.call_count == 0 async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "unknown"}) await hass.async_block_till_done() assert listener.call_count == 0