diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 144646301cd..70b95b1bb7f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,8 +8,18 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import tarfile from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch +from unittest.mock import ( + ANY, + DEFAULT, + AsyncMock, + MagicMock, + Mock, + call, + mock_open, + patch, +) import pytest @@ -33,6 +43,8 @@ from homeassistant.components.backup.manager import ( CreateBackupStage, CreateBackupState, NewBackup, + ReceiveBackupStage, + ReceiveBackupState, WrittenBackup, ) from homeassistant.core import HomeAssistant @@ -1429,8 +1441,12 @@ async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, ) -> None: """Test receive backup with a busy manager.""" + new_backup = NewBackup(backup_job_id="time-123") + backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() + create_backup.return_value = (new_backup, backup_task) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -1445,24 +1461,18 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["success"] is True - new_backup = NewBackup(backup_job_id="time-123") - backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() - with patch( - "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", - return_value=(new_backup, backup_task), - ) as create_backup: - await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": ["backup.local"]} - ) - result = await ws_client.receive_json() - assert result["event"] == { - "manager_state": "create_backup", - "stage": None, - "state": "in_progress", - } - result = await ws_client.receive_json() - assert result["success"] is True - assert result["result"] == {"backup_job_id": "time-123"} + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == {"backup_job_id": "time-123"} assert create_backup.call_count == 1 @@ -1488,6 +1498,732 @@ async def test_receive_backup_busy_manager( await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) +async def test_receive_backup_agent_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test upload error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id + backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id + backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + delete_backup = AsyncMock() + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch.object(remote_agent, "async_delete_backup", delete_backup), + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 1 + assert delete_backup.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [asyncio.CancelledError("Boom!")]) +async def test_receive_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test non agent upload error during backup receive.""" + hass_storage[DOMAIN] = { + "data": {}, + "key": DOMAIN, + "version": 1, + } + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert not hass_storage[DOMAIN]["data"] + assert resp.status == 500 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None), + (1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_receive_backup_file_write_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, +) -> None: + """Test file write error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", + [ + OSError("Boom!"), + tarfile.TarError("Boom!"), + json.JSONDecodeError("Boom!", "test", 1), + KeyError("Boom!"), + ], +) +async def test_receive_backup_read_tar_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + exception: Exception, +) -> None: + """Test read tar error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + side_effect=exception, + ) as read_backup, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert read_backup.call_count == 1 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + "final_state", + "response_status", + ), + [ + ( + 2, + [DEFAULT, OSError("Boom!")], + 0, + None, + 1, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + 2, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, OSError("Boom!")], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + ReceiveBackupState.FAILED, + 500, + ), + ], +) +async def test_receive_backup_file_read_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: list[Exception | None], + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + unlink_call_count: int, + unlink_exception: Exception | None, + final_state: ReceiveBackupState, + response_status: int, +) -> None: + """Test file read error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": final_state, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == response_status + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + @pytest.mark.parametrize( ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"), [