core/tests/components/hassio/test_backup.py

2572 lines
82 KiB
Python

"""Test supervisor backup functionality."""
from collections.abc import (
AsyncGenerator,
AsyncIterator,
Callable,
Coroutine,
Generator,
)
from dataclasses import replace
from datetime import datetime
from io import StringIO
import os
from pathlib import PurePath
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, patch
from uuid import UUID
from aiohasupervisor.exceptions import (
SupervisorBadRequestError,
SupervisorError,
SupervisorNotFoundError,
)
from aiohasupervisor.models import (
backups as supervisor_backups,
jobs as supervisor_jobs,
mounts as supervisor_mounts,
)
from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE
from aiohasupervisor.models.mounts import MountsInfo
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.backup import (
DOMAIN as BACKUP_DOMAIN,
AddonInfo,
AgentBackup,
BackupAgent,
BackupAgentPlatformProtocol,
Folder,
store as backup_store,
)
from homeassistant.components.hassio import DOMAIN
from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV
from homeassistant.core import HomeAssistant
from homeassistant.helpers.backup import async_initialize_backup
from homeassistant.setup import async_setup_component
from .test_init import MOCK_ENVIRON
from tests.common import mock_platform
from tests.typing import ClientSessionGenerator, WebSocketGenerator
TEST_BACKUP = supervisor_backups.Backup(
compressed=False,
content=supervisor_backups.BackupContent(
addons=["ssl"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant=True,
),
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
location_attributes={
LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes(
protected=False, size_bytes=1048576
)
},
name="Test",
slug="abc123",
type=supervisor_backups.BackupType.PARTIAL,
)
TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete(
addons=[
supervisor_backups.BackupAddon(
name="Terminal & SSH",
size=0.0,
slug="core_ssh",
version="9.14.0",
)
],
compressed=TEST_BACKUP.compressed,
date=TEST_BACKUP.date,
extra=None,
folders=[supervisor_backups.Folder.SHARE],
homeassistant_exclude_database=False,
homeassistant="2024.12.0",
location_attributes=TEST_BACKUP.location_attributes,
name=TEST_BACKUP.name,
repositories=[],
slug=TEST_BACKUP.slug,
supervisor_version="2024.11.2",
type=TEST_BACKUP.type,
)
TEST_BACKUP_2 = supervisor_backups.Backup(
compressed=False,
content=supervisor_backups.BackupContent(
addons=["ssl"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant=False,
),
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
location_attributes={
LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes(
protected=False, size_bytes=1048576
)
},
name="Test",
slug="abc123",
type=supervisor_backups.BackupType.PARTIAL,
)
TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete(
addons=[
supervisor_backups.BackupAddon(
name="Terminal & SSH",
size=0.0,
slug="core_ssh",
version="9.14.0",
)
],
compressed=TEST_BACKUP_2.compressed,
date=TEST_BACKUP_2.date,
extra=None,
folders=[supervisor_backups.Folder.SHARE],
homeassistant_exclude_database=False,
homeassistant=None,
location_attributes=TEST_BACKUP_2.location_attributes,
name=TEST_BACKUP_2.name,
repositories=[],
slug=TEST_BACKUP_2.slug,
supervisor_version="2024.11.2",
type=TEST_BACKUP_2.type,
)
TEST_BACKUP_3 = supervisor_backups.Backup(
compressed=False,
content=supervisor_backups.BackupContent(
addons=["ssl"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant=True,
),
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
location_attributes={
LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes(
protected=False, size_bytes=1048576
)
},
name="Test",
slug="abc123",
type=supervisor_backups.BackupType.PARTIAL,
)
TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete(
addons=[
supervisor_backups.BackupAddon(
name="Terminal & SSH",
size=0.0,
slug="core_ssh",
version="9.14.0",
)
],
compressed=TEST_BACKUP_3.compressed,
date=TEST_BACKUP_3.date,
extra=None,
folders=[supervisor_backups.Folder.SHARE],
homeassistant_exclude_database=False,
homeassistant=None,
location_attributes=TEST_BACKUP_3.location_attributes,
name=TEST_BACKUP_3.name,
repositories=[],
slug=TEST_BACKUP_3.slug,
supervisor_version="2024.11.2",
type=TEST_BACKUP_3.type,
)
TEST_BACKUP_4 = supervisor_backups.Backup(
compressed=False,
content=supervisor_backups.BackupContent(
addons=["ssl"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant=True,
),
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
location_attributes={
LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes(
protected=False, size_bytes=1048576
)
},
name="Test",
slug="abc123",
type=supervisor_backups.BackupType.PARTIAL,
)
TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete(
addons=[
supervisor_backups.BackupAddon(
name="Terminal & SSH",
size=0.0,
slug="core_ssh",
version="9.14.0",
)
],
compressed=TEST_BACKUP_4.compressed,
date=TEST_BACKUP_4.date,
extra=None,
folders=[supervisor_backups.Folder.SHARE],
homeassistant_exclude_database=True,
homeassistant="2024.12.0",
location_attributes=TEST_BACKUP_4.location_attributes,
name=TEST_BACKUP_4.name,
repositories=[],
slug=TEST_BACKUP_4.slug,
supervisor_version="2024.11.2",
type=TEST_BACKUP_4.type,
)
TEST_BACKUP_5 = supervisor_backups.Backup(
compressed=False,
content=supervisor_backups.BackupContent(
addons=["ssl"],
folders=[supervisor_backups.Folder.SHARE],
homeassistant=True,
),
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
location_attributes={
LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes(
protected=False, size_bytes=1048576
)
},
name="Test",
slug="abc123",
type=supervisor_backups.BackupType.PARTIAL,
)
TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete(
addons=[
supervisor_backups.BackupAddon(
name="Terminal & SSH",
size=0.0,
slug="core_ssh",
version="9.14.0",
)
],
compressed=TEST_BACKUP_5.compressed,
date=TEST_BACKUP_5.date,
extra=None,
folders=[supervisor_backups.Folder.SHARE],
homeassistant_exclude_database=False,
homeassistant="2024.12.0",
location_attributes=TEST_BACKUP_5.location_attributes,
name=TEST_BACKUP_5.name,
repositories=[],
slug=TEST_BACKUP_5.slug,
supervisor_version="2024.11.2",
type=TEST_BACKUP_5.type,
)
TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e"
TEST_JOB_NOT_DONE = supervisor_jobs.Job(
name="backup_manager_partial_backup",
reference="1ef41507",
uuid=UUID(TEST_JOB_ID),
progress=0.0,
stage="copy_additional_locations",
done=False,
errors=[],
created=datetime.fromisoformat("1970-01-01T00:00:00Z"),
child_jobs=[],
)
TEST_JOB_DONE = supervisor_jobs.Job(
name="backup_manager_partial_backup",
reference="1ef41507",
uuid=UUID(TEST_JOB_ID),
progress=0.0,
stage="copy_additional_locations",
done=True,
errors=[],
created=datetime.fromisoformat("1970-01-01T00:00:00Z"),
child_jobs=[],
)
TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job(
name="backup_manager_partial_restore",
reference="1ef41507",
uuid=UUID(TEST_JOB_ID),
progress=0.0,
stage="copy_additional_locations",
done=True,
errors=[
supervisor_jobs.JobError(
type="BackupInvalidError",
message=(
"Backup was made on supervisor version 2025.02.2.dev3105, "
"can't restore on 2025.01.2.dev3105"
),
)
],
created=datetime.fromisoformat("1970-01-01T00:00:00Z"),
child_jobs=[],
)
@pytest.fixture(autouse=True)
def fixture_supervisor_environ() -> Generator[None]:
"""Mock os environ for supervisor."""
with patch.dict(os.environ, MOCK_ENVIRON):
yield
@pytest.fixture(autouse=True)
async def hassio_enabled(
hass: HomeAssistant, supervisor_client: AsyncMock
) -> AsyncGenerator[None]:
"""Enable hassio."""
with (
patch("homeassistant.components.backup.is_hassio", return_value=True),
patch("homeassistant.components.backup.backup.is_hassio", return_value=True),
):
yield
@pytest.fixture
async def setup_backup_integration(
hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock
) -> None:
"""Set up Backup integration."""
async_initialize_backup(hass)
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
await hass.async_block_till_done()
class BackupAgentTest(BackupAgent):
"""Test backup agent."""
def __init__(self, name: str, domain: str = "test") -> None:
"""Initialize the backup agent."""
self.domain = domain
self.name = name
self.unique_id = name
async def async_download_backup(
self, backup_id: str, **kwargs: Any
) -> AsyncIterator[bytes]:
"""Download a backup file."""
return AsyncMock(spec_set=["__aiter__"])
async def async_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
**kwargs: Any,
) -> None:
"""Upload a backup."""
await open_stream()
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
return []
async def async_get_backup(
self, backup_id: str, **kwargs: Any
) -> AgentBackup | None:
"""Return a backup."""
return None
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
async def _setup_backup_platform(
hass: HomeAssistant,
*,
domain: str,
platform: BackupAgentPlatformProtocol,
) -> None:
"""Set up a mock domain."""
mock_platform(hass, f"{domain}.backup", platform)
assert await async_setup_component(hass, domain, {})
await hass.async_block_till_done()
@pytest.mark.usefixtures("hassio_client")
@pytest.mark.parametrize(
("mounts", "expected_agents"),
[
(
MountsInfo(default_backup_mount=None, mounts=[]),
[BackupAgentTest("local", DOMAIN)],
),
(
MountsInfo(
default_backup_mount=None,
mounts=[
supervisor_mounts.CIFSMountResponse(
share="test",
name="test",
read_only=False,
state=supervisor_mounts.MountState.ACTIVE,
user_path="test",
usage=supervisor_mounts.MountUsage.BACKUP,
server="test",
type=supervisor_mounts.MountType.CIFS,
)
],
),
[BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)],
),
(
MountsInfo(
default_backup_mount=None,
mounts=[
supervisor_mounts.CIFSMountResponse(
share="test",
name="test",
read_only=False,
state=supervisor_mounts.MountState.ACTIVE,
user_path="test",
usage=supervisor_mounts.MountUsage.MEDIA,
server="test",
type=supervisor_mounts.MountType.CIFS,
)
],
),
[BackupAgentTest("local", DOMAIN)],
),
],
)
async def test_agent_info(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
mounts: MountsInfo,
expected_agents: list[BackupAgent],
) -> None:
"""Test backup agent info."""
client = await hass_ws_client(hass)
supervisor_client.mounts.info.return_value = mounts
async_initialize_backup(hass)
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
await client.send_json_auto_id({"type": "backup/agents/info"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"agents": [
{"agent_id": agent.agent_id, "name": agent.name}
for agent in expected_agents
],
}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("backup", "backup_details", "expected_response"),
[
(
TEST_BACKUP,
TEST_BACKUP_DETAILS,
{
"addons": [
{"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"}
],
"agents": {"hassio.local": {"protected": False, "size": 1048576}},
"backup_id": "abc123",
"database_included": True,
"date": "1970-01-01T00:00:00+00:00",
"extra_metadata": {},
"failed_agent_ids": [],
"folders": ["share"],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"with_automatic_settings": None,
},
),
(
TEST_BACKUP_2,
TEST_BACKUP_DETAILS_2,
{
"addons": [
{"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"}
],
"agents": {"hassio.local": {"protected": False, "size": 1048576}},
"backup_id": "abc123",
"database_included": False,
"date": "1970-01-01T00:00:00+00:00",
"extra_metadata": {},
"failed_agent_ids": [],
"folders": ["share"],
"homeassistant_included": False,
"homeassistant_version": None,
"name": "Test",
"with_automatic_settings": None,
},
),
],
)
async def test_agent_list_backups(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
backup: supervisor_backups.Backup,
backup_details: supervisor_backups.BackupComplete,
expected_response: dict[str, Any],
) -> None:
"""Test agent list backups."""
client = await hass_ws_client(hass)
supervisor_client.backups.list.return_value = [backup, TEST_BACKUP_3]
supervisor_client.backups.backup_info.return_value = backup_details
await client.send_json_auto_id({"type": "backup/info"})
response = await client.receive_json()
assert response["success"]
assert response["result"]["backups"] == [expected_response]
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_download(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test agent download backup."""
client = await hass_client()
backup_id = "abc123"
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.backups.download_backup.return_value.__aiter__.return_value = (
iter((b"backup data",))
)
resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local")
assert resp.status == 200
assert await resp.content.read() == b"backup data"
supervisor_client.backups.download_backup.assert_called_once_with(
"abc123",
options=supervisor_backups.DownloadBackupOptions(
location=LOCATION_LOCAL_STORAGE
),
)
@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()
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={agent_id}")
assert resp.status == 404
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_upload(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test agent upload backup."""
client = await hass_client()
backup_id = "test-backup"
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
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=0,
)
supervisor_client.backups.reload.assert_not_called()
with (
patch("pathlib.Path.mkdir"),
patch("pathlib.Path.open"),
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
) as fetch_backup,
patch(
"homeassistant.components.backup.manager.read_backup",
return_value=test_backup,
),
patch("shutil.copy"),
):
fetch_backup.return_value = test_backup
resp = await client.post(
"/api/backup/upload?agent_id=hassio.local",
data={"file": StringIO("test")},
)
assert resp.status == 201
supervisor_client.backups.reload.assert_not_called()
supervisor_client.backups.download_backup.assert_not_called()
supervisor_client.backups.remove_backup.assert_not_called()
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_get_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test agent get backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
backup_id = "abc123"
await client.send_json_auto_id(
{
"type": "backup/details",
"backup_id": backup_id,
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"agent_errors": {},
"backup": {
"addons": [
{"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"}
],
"agents": {"hassio.local": {"protected": False, "size": 1048576}},
"backup_id": "abc123",
"database_included": True,
"date": "1970-01-01T00:00:00+00:00",
"extra_metadata": {},
"failed_agent_ids": [],
"folders": ["share"],
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"with_automatic_settings": None,
},
}
supervisor_client.backups.backup_info.assert_called_once_with(backup_id)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("backup_info_side_effect", "expected_response"),
[
(
SupervisorBadRequestError("blah"),
{
"success": True,
"result": {"agent_errors": {"hassio.local": "blah"}, "backup": None},
},
),
(
SupervisorNotFoundError(),
{
"success": True,
"result": {"agent_errors": {}, "backup": None},
},
),
],
)
async def test_agent_get_backup_with_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
backup_info_side_effect: Exception,
expected_response: dict[str, Any],
) -> None:
"""Test agent get backup."""
client = await hass_ws_client(hass)
backup_id = "abc123"
supervisor_client.backups.backup_info.side_effect = backup_info_side_effect
await client.send_json_auto_id(
{
"type": "backup/details",
"backup_id": backup_id,
}
)
response = await client.receive_json()
assert response == {"id": 1, "type": "result"} | expected_response
supervisor_client.backups.backup_info.assert_called_once_with(backup_id)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_delete_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test agent delete backup."""
client = await hass_ws_client(hass)
backup_id = "abc123"
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": {}}
supervisor_client.backups.remove_backup.assert_called_once_with(
backup_id,
options=supervisor_backups.RemoveBackupOptions(
location={LOCATION_LOCAL_STORAGE}
),
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("remove_side_effect", "expected_response"),
[
(
SupervisorBadRequestError("blah"),
{
"success": True,
"result": {"agent_errors": {"hassio.local": "blah"}},
},
),
(
SupervisorNotFoundError(),
{
"success": True,
"result": {"agent_errors": {}},
},
),
],
)
async def test_agent_delete_with_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
remove_side_effect: Exception,
expected_response: dict[str, Any],
) -> None:
"""Test agent delete backup."""
client = await hass_ws_client(hass)
backup_id = "abc123"
supervisor_client.backups.remove_backup.side_effect = remove_side_effect
await client.send_json_auto_id(
{
"type": "backup/delete",
"backup_id": backup_id,
}
)
response = await client.receive_json()
assert response == {"id": 1, "type": "result"} | expected_response
supervisor_client.backups.remove_backup.assert_called_once_with(
backup_id,
options=supervisor_backups.RemoveBackupOptions(
location={LOCATION_LOCAL_STORAGE}
),
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("event_data", "mount_info_calls"),
[
(
{
"event": "job",
"data": {"name": "mount_manager_create_mount", "done": True},
},
1,
),
(
{
"event": "job",
"data": {"name": "mount_manager_create_mount", "done": False},
},
0,
),
(
{
"event": "job",
"data": {"name": "mount_manager_remove_mount", "done": True},
},
1,
),
(
{
"event": "job",
"data": {"name": "mount_manager_remove_mount", "done": False},
},
0,
),
({"event": "job", "data": {"name": "other_job", "done": True}}, 0),
(
{
"event": "other_event",
"data": {"name": "mount_manager_remove_mount", "done": True},
},
0,
),
],
)
async def test_agents_notify_on_mount_added_removed(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
event_data: dict[str, Any],
mount_info_calls: int,
) -> None:
"""Test the listener is called when mounts are added or removed."""
client = await hass_ws_client(hass)
assert supervisor_client.mounts.info.call_count == 1
assert supervisor_client.mounts.info.call_args[0] == ()
supervisor_client.mounts.info.reset_mock()
await client.send_json_auto_id({"type": "supervisor/event", "data": event_data})
response = await client.receive_json()
assert response["success"]
await hass.async_block_till_done()
assert supervisor_client.mounts.info.call_count == mount_info_calls
DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions(
addons=None,
background=True,
compressed=True,
extra={
"instance_id": ANY,
"supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00",
"with_automatic_settings": False,
},
filename=PurePath("Test_2025-01-30_05.42_12345678.tar"),
folders={"ssl"},
homeassistant_exclude_database=False,
homeassistant=True,
location=[LOCATION_LOCAL_STORAGE],
name="Test",
password=None,
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("extra_generate_options", "expected_supervisor_options"),
[
(
{},
DEFAULT_BACKUP_OPTIONS,
),
(
{"include_addons": ["addon_1", "addon_2"]},
replace(DEFAULT_BACKUP_OPTIONS, addons={"addon_1", "addon_2"}),
),
(
{"include_all_addons": True},
replace(DEFAULT_BACKUP_OPTIONS, addons="ALL"),
),
(
{"include_database": False},
replace(DEFAULT_BACKUP_OPTIONS, homeassistant_exclude_database=True),
),
(
{"include_folders": ["media", "share"]},
replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}),
),
(
{
"include_folders": ["media"],
"include_database": False,
"include_homeassistant": False,
},
replace(
DEFAULT_BACKUP_OPTIONS,
folders={"media"},
homeassistant=False,
homeassistant_exclude_database=True,
),
),
],
)
async def test_reader_writer_create(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
extra_generate_options: dict[str, Any],
expected_supervisor_options: supervisor_backups.PartialBackupOptions,
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
| extra_generate_options
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
supervisor_client.backups.partial_backup.assert_called_once_with(
expected_supervisor_options
)
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "completed",
}
supervisor_client.backups.download_backup.assert_not_called()
supervisor_client.backups.remove_backup.assert_not_called()
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_report_progress(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
supervisor_client.backups.partial_backup.assert_called_once_with(
DEFAULT_BACKUP_OPTIONS
)
supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"}
supervisor_events = [
supervisor_event_base | {"done": False, "stage": "addon_repositories"},
supervisor_event_base | {"done": False, "stage": None}, # Will be skipped
supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped
supervisor_event_base | {"done": False, "stage": "home_assistant"},
supervisor_event_base | {"done": False, "stage": "addons"},
supervisor_event_base | {"done": True, "stage": "finishing_file"},
]
expected_manager_events = [
"addon_repositories",
"home_assistant",
"addons",
"finishing_file",
]
for supervisor_event in supervisor_events:
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {"event": "job", "data": supervisor_event},
}
)
acks = 0
events = []
for _ in range(len(supervisor_events) + len(expected_manager_events)):
response = await client.receive_json()
if "event" in response:
events.append(response)
continue
assert response["success"]
acks += 1
assert acks == len(supervisor_events)
assert len(events) == len(expected_manager_events)
for i, event in enumerate(events):
assert event["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": expected_manager_events[i],
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "completed",
}
supervisor_client.backups.download_backup.assert_not_called()
supervisor_client.backups.remove_backup.assert_not_called()
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_job_done(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
) -> None:
"""Test generating a backup, and backup job finishes early."""
client = await hass_ws_client(hass)
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
supervisor_client.backups.partial_backup.assert_called_once_with(
DEFAULT_BACKUP_OPTIONS
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "completed",
}
supervisor_client.backups.download_backup.assert_not_called()
supervisor_client.backups.remove_backup.assert_not_called()
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client")
@pytest.mark.parametrize(
(
"commands",
"password",
"agent_ids",
"password_sent_to_supervisor",
"create_locations",
"create_protected",
"upload_locations",
),
[
(
[],
None,
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
None,
[LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"],
False,
[],
),
(
[],
"hunter2",
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
"hunter2",
[LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"],
True,
[],
),
(
[
{
"type": "backup/config/update",
"agents": {
"hassio.local": {"protected": False},
},
}
],
"hunter2",
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
"hunter2",
["share1", "share2", "share3"],
True,
[LOCATION_LOCAL_STORAGE],
),
(
[
{
"type": "backup/config/update",
"agents": {
"hassio.local": {"protected": False},
"hassio.share1": {"protected": False},
},
}
],
"hunter2",
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
"hunter2",
["share2", "share3"],
True,
[LOCATION_LOCAL_STORAGE, "share1"],
),
(
[
{
"type": "backup/config/update",
"agents": {
"hassio.local": {"protected": False},
"hassio.share1": {"protected": False},
"hassio.share2": {"protected": False},
},
}
],
"hunter2",
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
None,
[LOCATION_LOCAL_STORAGE, "share1", "share2"],
True,
["share3"],
),
(
[
{
"type": "backup/config/update",
"agents": {
"hassio.local": {"protected": False},
},
}
],
"hunter2",
["hassio.local"],
None,
[LOCATION_LOCAL_STORAGE],
False,
[],
),
],
)
async def test_reader_writer_create_per_agent_encryption(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
commands: dict[str, Any],
password: str | None,
agent_ids: list[str],
password_sent_to_supervisor: str | None,
create_locations: list[str | None],
create_protected: bool,
upload_locations: list[str | None],
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
freezer.move_to("2025-01-30 13:42:12.345678")
mounts = MountsInfo(
default_backup_mount=None,
mounts=[
supervisor_mounts.CIFSMountResponse(
share=f"share{i}",
name=f"share{i}",
read_only=False,
state=supervisor_mounts.MountState.ACTIVE,
user_path=f"share{i}",
usage=supervisor_mounts.MountUsage.BACKUP,
server=f"share{i}",
type=supervisor_mounts.MountType.CIFS,
)
for i in range(1, 4)
],
)
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = replace(
TEST_BACKUP_DETAILS,
extra=DEFAULT_BACKUP_OPTIONS.extra,
location_attributes={
location: supervisor_backups.BackupLocationAttributes(
protected=create_protected,
size_bytes=1048576,
)
for location in create_locations
},
)
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
supervisor_client.mounts.info.return_value = mounts
async_initialize_backup(hass)
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
for command in commands:
await client.send_json_auto_id(command)
result = await client.receive_json()
assert result["success"] is True
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{
"type": "backup/generate",
"agent_ids": agent_ids,
"name": "Test",
"password": password,
}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
supervisor_client.backups.partial_backup.assert_called_once_with(
replace(
DEFAULT_BACKUP_OPTIONS,
password=password_sent_to_supervisor,
location=create_locations,
)
)
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "completed",
}
assert len(supervisor_client.backups.upload_backup.mock_calls) == len(
upload_locations
)
for call in supervisor_client.backups.upload_backup.mock_calls:
assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar")
upload_call_locations: set = call.args[1].location
assert len(upload_call_locations) == 1
assert upload_call_locations.pop() in upload_locations
supervisor_client.backups.remove_backup.assert_not_called()
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("side_effect", "error_code", "error_message", "expected_reason"),
[
(
SupervisorError("Boom!"),
"home_assistant_error",
"Error creating backup: Boom!",
"backup_manager_error",
),
(
Exception("Boom!"),
"unknown_error",
"Unknown error",
"unknown_error",
),
],
)
async def test_reader_writer_create_partial_backup_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
side_effect: Exception,
error_code: str,
error_message: str,
expected_reason: str,
) -> None:
"""Test client partial backup error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.side_effect = side_effect
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": expected_reason,
"stage": None,
"state": "failed",
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == error_code
assert response["error"]["message"] == error_message
assert supervisor_client.backups.partial_backup.call_count == 1
@pytest.mark.parametrize(
"supervisor_event",
[
# Missing backup reference
{
"event": "job",
"data": {
"done": True,
"uuid": TEST_JOB_ID,
},
},
# Errors
{
"event": "job",
"data": {
"done": True,
"errors": [
{
"type": "BackupMountDownError",
"message": "test_mount is down, cannot back-up to it",
}
],
"uuid": TEST_JOB_ID,
"reference": "test_slug",
},
},
],
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_missing_reference_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
supervisor_event: dict[str, Any],
) -> None:
"""Test missing reference error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
assert supervisor_client.backups.partial_backup.call_count == 1
await client.send_json_auto_id(
{"type": "supervisor/event", "data": supervisor_event}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": "upload_failed",
"stage": None,
"state": "failed",
}
await hass.async_block_till_done()
assert supervisor_client.backups.backup_info.call_count == 0
assert supervisor_client.backups.download_backup.call_count == 0
assert supervisor_client.backups.remove_backup.call_count == 0
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
@pytest.mark.parametrize(
("method", "download_call_count", "remove_call_count"),
[("download_backup", 1, 1), ("remove_backup", 1, 1)],
)
async def test_reader_writer_create_download_remove_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
exception: Exception,
method: str,
download_call_count: int,
remove_call_count: int,
) -> None:
"""Test download and remove error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
method_mock = getattr(supervisor_client.backups, method)
method_mock.side_effect = exception
remote_agent = BackupAgentTest("remote")
await _setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
assert supervisor_client.backups.partial_backup.call_count == 1
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": "upload_failed",
"stage": None,
"state": "failed",
}
await hass.async_block_till_done()
assert supervisor_client.backups.backup_info.call_count == 1
assert supervisor_client.backups.download_backup.call_count == download_call_count
assert supervisor_client.backups.remove_backup.call_count == remove_call_count
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
async def test_reader_writer_create_info_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
exception: Exception,
) -> None:
"""Test backup info error when generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.side_effect = exception
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
remote_agent = BackupAgentTest("remote")
await _setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
assert supervisor_client.backups.partial_backup.call_count == 1
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": "upload_failed",
"stage": None,
"state": "failed",
}
await hass.async_block_till_done()
assert supervisor_client.backups.backup_info.call_count == 1
assert supervisor_client.backups.download_backup.call_count == 0
assert supervisor_client.backups.remove_backup.call_count == 0
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_remote_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
) -> None:
"""Test generating a backup which will be uploaded to a remote agent."""
client = await hass_ws_client(hass)
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
remote_agent = BackupAgentTest("remote")
await _setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
supervisor_client.backups.partial_backup.assert_called_once_with(
replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]),
)
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {
"event": "job",
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
},
}
)
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "upload_to_agents",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "completed",
}
supervisor_client.backups.download_backup.assert_called_once_with("test_slug")
supervisor_client.backups.remove_backup.assert_called_once_with(
"test_slug",
options=supervisor_backups.RemoveBackupOptions({LOCATION_CLOUD_BACKUP}),
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize(
("extra_generate_options", "expected_error"),
[
(
{"include_homeassistant": False},
{
"code": "home_assistant_error",
"message": "Cannot create a backup with database but without Home Assistant",
},
),
(
{"include_homeassistant": False, "include_database": False},
{
"code": "unknown_error",
"message": "Unknown error",
},
),
],
)
async def test_reader_writer_create_wrong_parameters(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
extra_generate_options: dict[str, Any],
expected_error: dict[str, str],
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
| extra_generate_options
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": "unknown_error",
"stage": None,
"state": "failed",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "idle",
}
response = await client.receive_json()
assert not response["success"]
assert response["error"] == expected_error
supervisor_client.backups.partial_backup.assert_not_called()
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_agent_receive_remote_backup(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test receiving a backup which will be uploaded to a remote agent."""
client = await hass_client()
backup_id = "test-backup"
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
supervisor_client.backups.upload_backup.return_value = "test_slug"
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=0.0,
)
remote_agent = BackupAgentTest("remote")
await _setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
supervisor_client.backups.reload.assert_not_called()
with (
patch("pathlib.Path.mkdir"),
patch("pathlib.Path.open"),
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
) as fetch_backup,
patch(
"homeassistant.components.backup.manager.read_backup",
return_value=test_backup,
),
patch("shutil.copy"),
):
fetch_backup.return_value = test_backup
resp = await client.post(
"/api/backup/upload?agent_id=test.remote",
data={"file": StringIO("test")},
)
assert resp.status == 201
supervisor_client.backups.download_backup.assert_called_once_with("test_slug")
supervisor_client.backups.remove_backup.assert_called_once_with(
"test_slug",
options=supervisor_backups.RemoveBackupOptions({LOCATION_CLOUD_BACKUP}),
)
@pytest.mark.parametrize(
("get_job_result", "supervisor_events"),
[
(
TEST_JOB_NOT_DONE,
[{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}],
),
(
TEST_JOB_DONE,
[],
),
],
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
get_job_result: supervisor_jobs.Job,
supervisor_events: list[dict[str, Any]],
) -> None:
"""Test restoring a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.jobs.get_job.return_value = get_job_result
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {
"manager_state": "idle",
}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
supervisor_client.backups.partial_restore.assert_called_once_with(
"abc123",
supervisor_backups.PartialRestoreOptions(
addons=None,
background=True,
folders=None,
homeassistant=True,
location=LOCATION_LOCAL_STORAGE,
password=None,
),
)
for event in supervisor_events:
await client.send_json_auto_id({"type": "supervisor/event", "data": event})
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "completed",
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
assert response["result"] is None
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_report_progress(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restoring a backup."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {
"manager_state": "idle",
}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
supervisor_client.backups.partial_restore.assert_called_once_with(
"abc123",
supervisor_backups.PartialRestoreOptions(
addons=None,
background=True,
folders=None,
homeassistant=True,
location=LOCATION_LOCAL_STORAGE,
password=None,
),
)
supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"}
supervisor_events = [
supervisor_event_base | {"done": False, "stage": "addon_repositories"},
supervisor_event_base | {"done": False, "stage": None}, # Will be skipped
supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped
supervisor_event_base | {"done": False, "stage": "home_assistant"},
supervisor_event_base | {"done": True, "stage": "addons"},
]
expected_manager_events = [
"addon_repositories",
"home_assistant",
"addons",
]
for supervisor_event in supervisor_events:
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {"event": "job", "data": supervisor_event},
}
)
acks = 0
events = []
for _ in range(len(supervisor_events) + len(expected_manager_events)):
response = await client.receive_json()
if "event" in response:
events.append(response)
continue
assert response["success"]
acks += 1
assert acks == len(supervisor_events)
assert len(events) == len(expected_manager_events)
for i, event in enumerate(events):
assert event["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": expected_manager_events[i],
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "completed",
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
assert response["result"] is None
@pytest.mark.parametrize(
("supervisor_error", "expected_error_code", "expected_reason"),
[
(
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: 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 = supervisor_error
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
supervisor_client.backups.partial_restore.assert_called_once_with(
"abc123",
supervisor_backups.PartialRestoreOptions(
addons=None,
background=True,
folders=None,
homeassistant=True,
location=LOCATION_LOCAL_STORAGE,
password=None,
),
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": expected_reason,
"stage": None,
"state": "failed",
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["error"]["code"] == expected_error_code
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_late_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restoring a backup with error."""
client = await hass_ws_client(hass)
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert response["success"]
await client.send_json_auto_id(
{"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"}
)
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
supervisor_client.backups.partial_restore.assert_called_once_with(
"abc123",
supervisor_backups.PartialRestoreOptions(
addons=None,
background=True,
folders=None,
homeassistant=True,
location=LOCATION_LOCAL_STORAGE,
password=None,
),
)
event = {
"event": "job",
"data": {
"name": "backup_manager_partial_restore",
"reference": "7c54aeed",
"uuid": TEST_JOB_ID,
"progress": 0,
"stage": None,
"done": True,
"parent_id": None,
"errors": [
{
"type": "BackupInvalidError",
"message": (
"Backup was made on supervisor version 2025.02.2.dev3105, can't"
" restore on 2025.01.2.dev3105. Must update supervisor first."
),
}
],
"created": "2025-02-03T08:27:49.297997+00:00",
},
}
await client.send_json_auto_id({"type": "supervisor/event", "data": event})
response = await client.receive_json()
assert response["success"]
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": "backup_reader_writer_error",
"stage": None,
"state": "failed",
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "home_assistant_error",
"message": (
"Restore failed: [{'type': 'BackupInvalidError', 'message': \"Backup "
"was made on supervisor version 2025.02.2.dev3105, can't restore on "
'2025.01.2.dev3105. Must update supervisor first."}]'
),
}
@pytest.mark.parametrize(
("backup", "backup_details", "parameters", "expected_error"),
[
(
TEST_BACKUP,
TEST_BACKUP_DETAILS,
{"restore_database": False},
"Restore database must match backup",
),
(
TEST_BACKUP,
TEST_BACKUP_DETAILS,
{"restore_homeassistant": False},
"Cannot restore database without Home Assistant",
),
(
TEST_BACKUP_4,
TEST_BACKUP_DETAILS_4,
{"restore_homeassistant": True, "restore_database": True},
"Restore database must match backup",
),
],
)
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_wrong_parameters(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
backup: supervisor_backups.Backup,
backup_details: supervisor_backups.BackupComplete,
parameters: dict[str, Any],
expected_error: str,
) -> None:
"""Test trigger restore."""
client = await hass_ws_client(hass)
supervisor_client.backups.list.return_value = [backup]
supervisor_client.backups.backup_info.return_value = backup_details
default_parameters = {
"type": "backup/restore",
"agent_id": "hassio.local",
"backup_id": "abc123",
}
await client.send_json_auto_id(default_parameters | parameters)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "home_assistant_error",
"message": expected_error,
}
@pytest.mark.parametrize(
("get_job_result", "last_non_idle_event"),
[
(
TEST_JOB_DONE,
{
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "completed",
},
),
(
TEST_RESTORE_JOB_DONE_WITH_ERROR,
{
"manager_state": "restore_backup",
"reason": "unknown_error",
"stage": None,
"state": "failed",
},
),
],
)
@pytest.mark.usefixtures("hassio_client")
async def test_restore_progress_after_restart(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
get_job_result: supervisor_jobs.Job,
last_non_idle_event: dict[str, Any],
) -> None:
"""Test restore backup progress after restart."""
supervisor_client.jobs.get_job.return_value = get_job_result
async_initialize_backup(hass)
with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}):
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/info"})
response = await client.receive_json()
assert response["success"]
assert response["result"]["last_non_idle_event"] == last_non_idle_event
assert response["result"]["state"] == "idle"
@pytest.mark.usefixtures("hassio_client")
async def test_restore_progress_after_restart_report_progress(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restore backup progress after restart."""
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
async_initialize_backup(hass)
with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}):
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
assert response["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": None,
"state": "in_progress",
}
response = await client.receive_json()
assert response["success"]
supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"}
supervisor_events = [
supervisor_event_base | {"done": False, "stage": "addon_repositories"},
supervisor_event_base | {"done": False, "stage": None}, # Will be skipped
supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped
supervisor_event_base | {"done": False, "stage": "home_assistant"},
supervisor_event_base | {"done": True, "stage": "addons"},
]
expected_manager_events = ["addon_repositories", "home_assistant", "addons"]
expected_manager_states = ["in_progress", "in_progress", "completed"]
for supervisor_event in supervisor_events:
await client.send_json_auto_id(
{
"type": "supervisor/event",
"data": {"event": "job", "data": supervisor_event},
}
)
acks = 0
events = []
for _ in range(len(supervisor_events) + len(expected_manager_events)):
response = await client.receive_json()
if "event" in response:
events.append(response)
continue
assert response["success"]
acks += 1
assert acks == len(supervisor_events)
assert len(events) == len(expected_manager_events)
for i, event in enumerate(events):
assert event["event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": expected_manager_events[i],
"state": expected_manager_states[i],
}
response = await client.receive_json()
assert response["event"] == {"manager_state": "idle"}
await client.send_json_auto_id({"type": "backup/info"})
response = await client.receive_json()
assert response["success"]
assert response["result"]["last_non_idle_event"] == {
"manager_state": "restore_backup",
"reason": None,
"stage": "addons",
"state": "completed",
}
assert response["result"]["state"] == "idle"
@pytest.mark.usefixtures("hassio_client")
async def test_restore_progress_after_restart_unknown_job(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restore backup progress after restart."""
supervisor_client.jobs.get_job.side_effect = SupervisorError
async_initialize_backup(hass)
with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}):
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/info"})
response = await client.receive_json()
assert response["success"]
assert response["result"]["last_non_idle_event"] is None
assert response["result"]["state"] == "idle"
@pytest.mark.parametrize(
"storage_data",
[
{},
{
"backup": {
"data": {
"backups": [],
"config": {
"agents": {},
"automatic_backups_configured": True,
"create_backup": {
"agent_ids": ["test-agent1", "hassio.local", "test-agent2"],
"include_addons": ["addon1", "addon2"],
"include_all_addons": True,
"include_database": True,
"include_folders": ["media", "share"],
"name": None,
"password": None,
},
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"schedule": {
"days": [],
"recurrence": "never",
"state": "never",
"time": None,
},
},
},
"key": DOMAIN,
"version": backup_store.STORAGE_VERSION,
"minor_version": backup_store.STORAGE_VERSION_MINOR,
},
},
{
"backup": {
"data": {
"backups": [],
"config": {
"agents": {},
"automatic_backups_configured": True,
"create_backup": {
"agent_ids": ["test-agent1", "backup.local", "test-agent2"],
"include_addons": ["addon1", "addon2"],
"include_all_addons": False,
"include_database": True,
"include_folders": ["media", "share"],
"name": None,
"password": None,
},
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"schedule": {
"days": [],
"recurrence": "never",
"state": "never",
"time": None,
},
},
},
"key": DOMAIN,
"version": backup_store.STORAGE_VERSION,
"minor_version": backup_store.STORAGE_VERSION_MINOR,
},
},
],
)
@pytest.mark.usefixtures("hassio_client")
async def test_config_load_config_info(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
hass_storage: dict[str, Any],
storage_data: dict[str, Any] | None,
) -> None:
"""Test loading stored backup config and reading it via config/info."""
client = await hass_ws_client(hass)
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-13T12:01:00+01:00")
hass_storage.update(storage_data)
async_initialize_backup(hass)
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/config/info"})
assert await client.receive_json() == snapshot