Correct backup filename on delete or download of cloud backup (#138704)

* Correct backup filename on delete or download of cloud backup

* Improve tests

* Address review comments
This commit is contained in:
Erik Montnemery 2025-02-17 15:38:28 +01:00 committed by GitHub
parent 51aea58c7a
commit 4cdc3de94a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 21 deletions

View File

@ -11,7 +11,11 @@ from typing import Any
from aiohttp import ClientError from aiohttp import ClientError
from hass_nabucasa import Cloud, CloudError from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.api import CloudApiNonRetryableError
from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from hass_nabucasa.cloud_api import (
FilesHandlerListEntry,
async_files_delete_file,
async_files_list,
)
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
@ -76,11 +80,6 @@ class CloudBackupAgent(BackupAgent):
self._cloud = cloud self._cloud = cloud
self._hass = hass self._hass = hass
@callback
def _get_backup_filename(self) -> str:
"""Return the backup filename."""
return f"{self._cloud.client.prefs.instance_id}.tar"
async def async_download_backup( async def async_download_backup(
self, self,
backup_id: str, backup_id: str,
@ -91,13 +90,13 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups. :param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes. :return: An async iterator that yields bytes.
""" """
if not await self.async_get_backup(backup_id): if not (backup := await self._async_get_backup(backup_id)):
raise BackupAgentError("Backup not found") raise BackupAgentError("Backup not found")
try: try:
content = await self._cloud.files.download( content = await self._cloud.files.download(
storage_type=StorageType.BACKUP, storage_type=StorageType.BACKUP,
filename=self._get_backup_filename(), filename=backup["Key"],
) )
except CloudError as err: except CloudError as err:
raise BackupAgentError(f"Failed to download backup: {err}") from err raise BackupAgentError(f"Failed to download backup: {err}") from err
@ -124,7 +123,7 @@ class CloudBackupAgent(BackupAgent):
base64md5hash = await calculate_b64md5(open_stream, size) base64md5hash = await calculate_b64md5(open_stream, size)
except FilesError as err: except FilesError as err:
raise BackupAgentError(err) from err raise BackupAgentError(err) from err
filename = self._get_backup_filename() filename = f"{self._cloud.client.prefs.instance_id}.tar"
metadata = backup.as_dict() metadata = backup.as_dict()
tries = 1 tries = 1
@ -172,29 +171,34 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups. :param backup_id: The ID of the backup that was returned in async_list_backups.
""" """
if not await self.async_get_backup(backup_id): if not (backup := await self._async_get_backup(backup_id)):
return return
try: try:
await async_files_delete_file( await async_files_delete_file(
self._cloud, self._cloud,
storage_type=StorageType.BACKUP, storage_type=StorageType.BACKUP,
filename=self._get_backup_filename(), filename=backup["Key"],
) )
except (ClientError, CloudError) as err: except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to delete backup") from err raise BackupAgentError("Failed to delete backup") from err
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
backups = await self._async_list_backups()
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
async def _async_list_backups(self) -> list[FilesHandlerListEntry]:
"""List backups.""" """List backups."""
try: try:
backups = await async_files_list( backups = await async_files_list(
self._cloud, storage_type=StorageType.BACKUP self._cloud, storage_type=StorageType.BACKUP
) )
_LOGGER.debug("Cloud backups: %s", backups)
except (ClientError, CloudError) as err: except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to list backups") from err raise BackupAgentError("Failed to list backups") from err
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] _LOGGER.debug("Cloud backups: %s", backups)
return backups
async def async_get_backup( async def async_get_backup(
self, self,
@ -202,10 +206,19 @@ class CloudBackupAgent(BackupAgent):
**kwargs: Any, **kwargs: Any,
) -> AgentBackup | None: ) -> AgentBackup | None:
"""Return a backup.""" """Return a backup."""
backups = await self.async_list_backups() if not (backup := await self._async_get_backup(backup_id)):
return None
return AgentBackup.from_dict(backup["Metadata"])
async def _async_get_backup(
self,
backup_id: str,
) -> FilesHandlerListEntry | None:
"""Return a backup."""
backups = await self._async_list_backups()
for backup in backups: for backup in backups:
if backup.backup_id == backup_id: if backup["Metadata"]["backup_id"] == backup_id:
return backup return backup
return None return None

View File

@ -3,12 +3,12 @@
from collections.abc import AsyncGenerator, Generator from collections.abc import AsyncGenerator, Generator
from io import StringIO from io import StringIO
from typing import Any from typing import Any
from unittest.mock import Mock, PropertyMock, patch from unittest.mock import ANY, Mock, PropertyMock, patch
from aiohttp import ClientError from aiohttp import ClientError
from hass_nabucasa import CloudError from hass_nabucasa import CloudError
from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.api import CloudApiNonRetryableError
from hass_nabucasa.files import FilesError from hass_nabucasa.files import FilesError, StorageType
import pytest import pytest
from homeassistant.components.backup import ( from homeassistant.components.backup import (
@ -90,7 +90,26 @@ def mock_list_files() -> Generator[MagicMock]:
"size": 34519040, "size": 34519040,
"storage-type": "backup", "storage-type": "backup",
}, },
} },
{
"Key": "462e16810d6841228828d9dd2f9e341f.tar",
"LastModified": "2024-11-22T10:49:01.182Z",
"Size": 34519040,
"Metadata": {
"addons": [],
"backup_id": "23e64aed",
"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 yield list_files
@ -148,7 +167,21 @@ async def test_agents_list_backups(
"name": "Core 2024.12.0.dev0", "name": "Core 2024.12.0.dev0",
"failed_agent_ids": [], "failed_agent_ids": [],
"with_automatic_settings": None, "with_automatic_settings": None,
} },
{
"addons": [],
"agents": {"cloud.cloud": {"protected": False, "size": 34519040}},
"backup_id": "23e64aed",
"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,
},
] ]
@ -242,6 +275,10 @@ async def test_agents_download(
resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud")
assert resp.status == 200 assert resp.status == 200
assert await resp.content.read() == b"backup data" assert await resp.content.read() == b"backup data"
cloud.files.download.assert_called_once_with(
filename="462e16810d6841228828d9dd2f9e341e.tar",
storage_type=StorageType.BACKUP,
)
@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
@ -317,7 +354,14 @@ async def test_agents_upload(
data={"file": StringIO(backup_data)}, data={"file": StringIO(backup_data)},
) )
assert len(cloud.files.upload.mock_calls) == 1 cloud.files.upload.assert_called_once_with(
storage_type=StorageType.BACKUP,
open_stream=ANY,
filename=f"{cloud.client.prefs.instance_id}.tar",
base64md5hash=ANY,
metadata=ANY,
size=ANY,
)
metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"]
assert metadata["backup_id"] == backup_id assert metadata["backup_id"] == backup_id
@ -552,6 +596,7 @@ async def test_agents_upload_wrong_size(
async def test_agents_delete( async def test_agents_delete(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
cloud: Mock,
mock_delete_file: Mock, mock_delete_file: Mock,
) -> None: ) -> None:
"""Test agent delete backup.""" """Test agent delete backup."""
@ -568,7 +613,11 @@ async def test_agents_delete(
assert response["success"] assert response["success"]
assert response["result"] == {"agent_errors": {}} assert response["result"] == {"agent_errors": {}}
mock_delete_file.assert_called_once() mock_delete_file.assert_called_once_with(
cloud,
filename="462e16810d6841228828d9dd2f9e341e.tar",
storage_type=StorageType.BACKUP,
)
@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) @pytest.mark.parametrize("side_effect", [ClientError, CloudError])