diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 0a2531900ae..8093ac88338 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -83,7 +83,7 @@ class BackupAgent(abc.ABC): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup. Raises BackupNotFound if the backup does not exist. diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 20ad613933b..8f241e6363d 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -15,6 +15,7 @@ from multidict import istr from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import frame from homeassistant.util import slugify from . import util @@ -66,7 +67,12 @@ class DownloadBackupView(HomeAssistantView): # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 - if backup is None: + if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) return Response(status=HTTPStatus.NOT_FOUND) headers = { diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4f3ea8b296c..bfaa5c5a48e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -30,6 +30,7 @@ from homeassistant.backup_restore import ( from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + frame, instance_id, integration_platform, issue_registry as ir, @@ -665,6 +666,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not result: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) continue if backup is None: if known_backup := self.known_backups.get(backup_id): @@ -1280,6 +1286,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1376,6 +1387,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 17e3ca8b176..6ecb508d9e9 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,17 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] dict({ 'error': dict({ @@ -4930,6 +4941,18 @@ 'type': 'result', }) # --- +# name: test_details_get_backup_returns_none + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_details_with_errors[BackupAgentUnreachableError] dict({ 'id': 1, @@ -5728,6 +5751,17 @@ # name: test_restore_remote_agent[remote_agents1-backups1].1 1 # --- +# name: test_restore_remote_agent_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_restore_wrong_password dict({ 'error': dict({ diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index a03217beac2..92bf454095e 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -234,6 +234,26 @@ async def test_downloading_backup_not_found( assert resp.status == 404 +async def test_downloading_backup_not_found_get_backup_returns_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test downloading a backup file that does not exist.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.test"]) + mock_agents["test.test"].async_get_backup.return_value = None + mock_agents["test.test"].async_get_backup.side_effect = None + + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 404 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_downloading_as_non_admin( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 404ba52de4b..d89e68f4ed8 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -234,6 +234,31 @@ async def test_details_with_errors( assert await client.receive_json() == snapshot +async def test_details_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch("pathlib.Path.exists", return_value=True): + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + @pytest.mark.parametrize( ("remote_agents", "backups"), [ @@ -724,6 +749,36 @@ async def test_restore_remote_agent( assert len(restart_calls) == snapshot +async def test_restore_remote_agent_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "test.remote", + } + ) + assert await client.receive_json() == snapshot + assert len(restart_calls) == 0 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_restore_wrong_password( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -3543,3 +3598,32 @@ async def test_can_decrypt_on_download_with_agent_error( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test can decrypt on download when the agent returns None from get_backup.""" + + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + )