Skip to content

Commit

Permalink
Improve error handling when supervisor backups are deleted
Browse files Browse the repository at this point in the history
  • Loading branch information
emontnemery committed Feb 4, 2025
1 parent 345cbc6 commit 650e72d
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 35 deletions.
2 changes: 2 additions & 0 deletions homeassistant/components/backup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
BackupAgent,
BackupAgentError,
BackupAgentPlatformProtocol,
BackupNotFound,
LocalBackupAgent,
)
from .const import DATA_MANAGER, DOMAIN
Expand Down Expand Up @@ -48,6 +49,7 @@
"BackupAgentError",
"BackupAgentPlatformProtocol",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/backup/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from homeassistant.core import HomeAssistant, callback

from .models import AgentBackup, BackupError
from .models import AgentBackup, BackupError, BackupManagerError


class BackupAgentError(BackupError):
Expand All @@ -27,7 +27,7 @@ class BackupAgentUnreachableError(BackupAgentError):
_message = "The backup agent is unreachable."


class BackupNotFound(BackupAgentError):
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""

error_code = "backup_not_found"
Expand Down
17 changes: 10 additions & 7 deletions homeassistant/components/backup/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from homeassistant.util import slugify

from . import util
from .agent import BackupAgent
from .agent import BackupAgent, BackupNotFound
from .const import DATA_MANAGER
from .manager import BackupManager

Expand Down Expand Up @@ -69,13 +69,16 @@ async def get(
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}

if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
try:
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)

async def _send_backup_no_password(
self,
Expand Down
15 changes: 8 additions & 7 deletions homeassistant/components/backup/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupReaderWriterError,
BaseBackup,
Folder,
)
from .store import BackupStore
from .util import (
AsyncIteratorReader,
Expand Down Expand Up @@ -274,12 +281,6 @@ async def async_resume_restore_progress_after_restart(
"""Get restore events after core restart."""


class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""

error_code = "backup_reader_writer_error"


class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""

Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/backup/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,9 @@ class BackupManagerError(BackupError):
"""Backup manager error."""

error_code = "backup_manager_error"


class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""

error_code = "backup_reader_writer_error"
5 changes: 5 additions & 0 deletions homeassistant/components/backup/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv

from .agent import BackupNotFound
from .config import Day, ScheduleRecurrence
from .const import DATA_MANAGER, LOGGER
from .manager import (
Expand Down Expand Up @@ -151,6 +152,8 @@ async def handle_restore(
restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"],
)
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else:
Expand Down Expand Up @@ -179,6 +182,8 @@ async def handle_can_decrypt_on_download(
agent_id=msg["agent_id"],
password=msg.get("password"),
)
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
except DecryptOnDowloadNotSupported:
Expand Down
16 changes: 12 additions & 4 deletions homeassistant/components/hassio/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
AgentBackup,
BackupAgent,
BackupManagerError,
BackupNotFound,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
Expand Down Expand Up @@ -162,10 +163,15 @@ async def async_download_backup(
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(location=self.location),
)
try:
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(
location=self.location
),
)
except SupervisorNotFoundError as err:
raise BackupNotFound from err

async def async_upload_backup(
self,
Expand Down Expand Up @@ -528,6 +534,8 @@ async def async_restore_backup(
location=restore_location,
),
)
except SupervisorNotFoundError as err:
raise BackupNotFound from err
except SupervisorBadRequestError as err:
# Supervisor currently does not transmit machine parsable error types
message = err.args[0]
Expand Down
22 changes: 22 additions & 0 deletions tests/components/backup/snapshots/test_websocket.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,28 @@
'type': 'result',
})
# ---
# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError]
dict({
'error': dict({
'code': 'home_assistant_error',
'message': 'Unknown error',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound]
dict({
'error': dict({
'code': 'backup_not_found',
'message': 'Backup not found',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_config_info[storage_data0]
dict({
'id': 1,
Expand Down
52 changes: 51 additions & 1 deletion tests/components/backup/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
from aiohttp import web
import pytest

from homeassistant.components.backup import AddonInfo, AgentBackup, Folder
from homeassistant.components.backup import (
AddonInfo,
AgentBackup,
BackupAgentError,
BackupNotFound,
Folder,
)
from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
from homeassistant.core import HomeAssistant

Expand Down Expand Up @@ -141,6 +147,50 @@ async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]
await _test_downloading_encrypted_backup(hass_client, "domain.test")


@pytest.mark.parametrize(
("error", "status"),
[
(BackupAgentError, 500),
(BackupNotFound, 404),
],
)
@patch.object(BackupAgentTest, "async_download_backup")
async def test_downloading_remote_encrypted_backup_with_error(
download_mock,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
error: Exception,
status: int,
) -> None:
"""Test downloading a local backup file."""
await setup_backup_integration(hass)
hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest(
"test",
[
AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
backup_id="abc123",
database_included=True,
date="1970-01-01T00:00:00Z",
extra_metadata={},
folders=[Folder.MEDIA, Folder.SHARE],
homeassistant_included=True,
homeassistant_version="2024.12.0",
name="Test",
protected=True,
size=13,
)
],
)

download_mock.side_effect = error
client = await hass_client()
resp = await client.get(
"/api/backup/download/abc123?agent_id=domain.test&password=blah"
)
assert resp.status == status


async def _test_downloading_encrypted_backup(
hass_client: ClientSessionGenerator,
agent_id: str,
Expand Down
37 changes: 37 additions & 0 deletions tests/components/backup/test_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AgentBackup,
BackupAgentError,
BackupAgentPlatformProtocol,
BackupNotFound,
BackupReaderWriterError,
Folder,
store,
Expand Down Expand Up @@ -2967,3 +2968,39 @@ async def test_can_decrypt_on_download(
}
)
assert await client.receive_json() == snapshot


@pytest.mark.parametrize(
"error",
[
BackupAgentError,
BackupNotFound,
],
)
@pytest.mark.usefixtures("mock_backups")
async def test_can_decrypt_on_download_with_agent_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
error: Exception,
) -> None:
"""Test can decrypt on download."""

await setup_backup_integration(
hass,
with_hassio=False,
backups={"test.remote": [TEST_BACKUP_ABC123]},
remote_agents=["remote"],
)
client = await hass_ws_client(hass)

with patch.object(BackupAgentTest, "async_download_backup", side_effect=error):
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
41 changes: 27 additions & 14 deletions tests/components/hassio/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,22 +582,29 @@ async def test_agent_download(
)


@pytest.mark.parametrize(
("backup_info", "backup_id", "agent_id"),
[
(TEST_BACKUP_DETAILS_3, "unknown", "hassio.local"),
(TEST_BACKUP_DETAILS_3, TEST_BACKUP_DETAILS_3.slug, "hassio.local"),
(TEST_BACKUP_DETAILS, TEST_BACKUP_DETAILS_3.slug, "hassio.local"),
],
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_download_unavailable_backup(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
supervisor_client: AsyncMock,
agent_id: str,
backup_id: str,
backup_info: supervisor_backups.BackupComplete,
) -> None:
"""Test agent download backup which does not exist."""
client = await hass_client()
backup_id = "abc123"
supervisor_client.backups.list.return_value = [TEST_BACKUP_3]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3
supervisor_client.backups.download_backup.return_value.__aiter__.return_value = (
iter((b"backup data",))
)
supervisor_client.backups.backup_info.return_value = backup_info
supervisor_client.backups.download_backup.side_effect = SupervisorNotFoundError

resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local")
resp = await client.get(f"/api/backup/download/{backup_id}?agent_id={agent_id}")
assert resp.status == 404


Expand Down Expand Up @@ -2126,30 +2133,36 @@ async def test_reader_writer_restore_report_progress(


@pytest.mark.parametrize(
("supervisor_error_string", "expected_error_code", "expected_reason"),
("supervisor_error", "expected_error_code", "expected_reason"),
[
("Invalid password for backup", "password_incorrect", "password_incorrect"),
(
"Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.",
SupervisorBadRequestError("Invalid password for backup"),
"password_incorrect",
"password_incorrect",
),
(
SupervisorBadRequestError(
"Backup was made on supervisor version 2025.12.0, can't "
"restore on 2024.12.0. Must update supervisor first."
),
"home_assistant_error",
"unknown_error",
),
(SupervisorNotFoundError(), "backup_not_found", "backup_not_found"),
],
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
supervisor_error_string: str,
supervisor_error: Exception,
expected_error_code: str,
expected_reason: str,
) -> None:
"""Test restoring a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError(
supervisor_error_string
)
supervisor_client.backups.partial_restore.side_effect = supervisor_error
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS

Expand Down

0 comments on commit 650e72d

Please sign in to comment.