Add support for per-backup agent encryption flag (#136622)

* Add support for per-backup agent encryption flag

* Adjust

* Don't attempt decrypting an unprotected backup

* Address review comments

* Add some tests

* Add fixture

* Rename fixture

* Correct condition for when we should encrypt or decrypt

* Update tests in integrations

* Improve test coverage

* Fix onedrive tests

* Add test

* Improve cipher worker shutdown

* Improve test coverage

* Fix google_drive tests

* Move inner class _CipherBackupStreamer._WorkerStatus to module scope
This commit is contained in:
Erik Montnemery 2025-01-29 14:04:17 +01:00 committed by GitHub
parent 3e513dda62
commit 9a687e7f94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1791 additions and 348 deletions

View File

@ -40,6 +40,7 @@ BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
agents: dict[str, StoredAgentConfig]
create_backup: StoredCreateBackupConfig
last_attempted_automatic_backup: str | None
last_completed_automatic_backup: str | None
@ -51,6 +52,7 @@ class StoredBackupConfig(TypedDict):
class BackupConfigData:
"""Represent loaded backup config data."""
agents: dict[str, AgentConfig]
create_backup: CreateBackupConfig
last_attempted_automatic_backup: datetime | None = None
last_completed_automatic_backup: datetime | None = None
@ -84,6 +86,10 @@ class BackupConfigData:
days = [Day(day) for day in data["schedule"]["days"]]
return cls(
agents={
agent_id: AgentConfig(protected=agent_data["protected"])
for agent_id, agent_data in data["agents"].items()
},
create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"],
include_addons=data["create_backup"]["include_addons"],
@ -120,6 +126,9 @@ class BackupConfigData:
last_completed = None
return StoredBackupConfig(
agents={
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
},
create_backup=self.create_backup.to_dict(),
last_attempted_automatic_backup=last_attempted,
last_completed_automatic_backup=last_completed,
@ -134,6 +143,7 @@ class BackupConfig:
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
"""Initialize backup config."""
self.data = BackupConfigData(
agents={},
create_backup=CreateBackupConfig(),
retention=RetentionConfig(),
schedule=BackupSchedule(),
@ -149,11 +159,20 @@ class BackupConfig:
async def update(
self,
*,
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
) -> None:
"""Update config."""
if agents is not UNDEFINED:
for agent_id, agent_config in agents.items():
if agent_id not in self.data.agents:
self.data.agents[agent_id] = AgentConfig(**agent_config)
else:
self.data.agents[agent_id] = replace(
self.data.agents[agent_id], **agent_config
)
if create_backup is not UNDEFINED:
self.data.create_backup = replace(self.data.create_backup, **create_backup)
if retention is not UNDEFINED:
@ -170,6 +189,31 @@ class BackupConfig:
self._manager.store.save()
@dataclass(kw_only=True)
class AgentConfig:
"""Represent the config for an agent."""
protected: bool
def to_dict(self) -> StoredAgentConfig:
"""Convert agent config to a dict."""
return {
"protected": self.protected,
}
class StoredAgentConfig(TypedDict):
"""Represent the stored config for an agent."""
protected: bool
class AgentParametersDict(TypedDict, total=False):
"""Represent the parameters for an agent."""
protected: bool
@dataclass(kw_only=True)
class RetentionConfig:
"""Represent the backup retention configuration."""

View File

@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
if not password:
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView):
worker_done_event = asyncio.Event()
def on_done() -> None:
def on_done(error: Exception | None) -> None:
"""Call by the worker thread when it's done."""
hass.loop.call_soon_threadsafe(worker_done_event.set)
stream = util.AsyncIteratorWriter(hass)
worker = threading.Thread(
target=util.decrypt_backup, args=[reader, stream, password, on_done]
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
)
try:
worker.start()

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import abc
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import StrEnum
import hashlib
import io
@ -46,10 +46,12 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupError, BackupManagerError, Folder
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .store import BackupStore
from .util import (
AsyncIteratorReader,
DecryptedBackupStreamer,
EncryptedBackupStreamer,
make_backup_dir,
read_backup,
validate_password,
@ -65,10 +67,18 @@ class NewBackup:
@dataclass(frozen=True, kw_only=True, slots=True)
class ManagerBackup(AgentBackup):
class AgentBackupStatus:
"""Agent specific backup attributes."""
protected: bool
size: int
@dataclass(frozen=True, kw_only=True, slots=True)
class ManagerBackup(BaseBackup):
"""Backup class."""
agent_ids: list[str]
agents: dict[str, AgentBackupStatus]
failed_agent_ids: list[str]
with_automatic_settings: bool | None
@ -437,20 +447,61 @@ class BackupManager:
backup: AgentBackup,
agent_ids: list[str],
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
) -> dict[str, Exception]:
"""Upload a backup to selected agents."""
agent_errors: dict[str, Exception] = {}
LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids)
sync_backup_results = await asyncio.gather(
*(
self.backup_agents[agent_id].async_upload_backup(
open_stream=open_stream,
backup=backup,
async def upload_backup_to_agent(agent_id: str) -> None:
"""Upload backup to a single agent, and encrypt or decrypt as needed."""
config = self.config.data.agents.get(agent_id)
should_encrypt = config.protected if config else password is not None
streamer: DecryptedBackupStreamer | EncryptedBackupStreamer | None = None
if should_encrypt == backup.protected or password is None:
# The backup we're uploading is already in the correct state, or we
# don't have a password to encrypt or decrypt it
LOGGER.debug(
"Uploading backup %s to agent %s as is", backup.backup_id, agent_id
)
for agent_id in agent_ids
),
open_stream_func = open_stream
_backup = backup
elif should_encrypt:
# The backup we're uploading is not encrypted, but the agent requires it
LOGGER.debug(
"Uploading encrypted backup %s to agent %s",
backup.backup_id,
agent_id,
)
streamer = EncryptedBackupStreamer(
self.hass, backup, open_stream, password
)
else:
# The backup we're uploading is encrypted, but the agent requires it
# decrypted
LOGGER.debug(
"Uploading decrypted backup %s to agent %s",
backup.backup_id,
agent_id,
)
streamer = DecryptedBackupStreamer(
self.hass, backup, open_stream, password
)
if streamer:
open_stream_func = streamer.open_stream
_backup = replace(
backup, protected=should_encrypt, size=streamer.size()
)
await self.backup_agents[agent_id].async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
)
if streamer:
await streamer.wait()
sync_backup_results = await asyncio.gather(
*(upload_backup_to_agent(agent_id) for agent_id in agent_ids),
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
@ -506,7 +557,7 @@ class BackupManager:
agent_backup, await instance_id.async_get(self.hass)
)
backups[backup_id] = ManagerBackup(
agent_ids=[],
agents={},
addons=agent_backup.addons,
backup_id=backup_id,
date=agent_backup.date,
@ -517,11 +568,12 @@ class BackupManager:
homeassistant_included=agent_backup.homeassistant_included,
homeassistant_version=agent_backup.homeassistant_version,
name=agent_backup.name,
protected=agent_backup.protected,
size=agent_backup.size,
with_automatic_settings=with_automatic_settings,
)
backups[backup_id].agent_ids.append(agent_ids[idx])
backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
protected=agent_backup.protected,
size=agent_backup.size,
)
return (backups, agent_errors)
@ -557,7 +609,7 @@ class BackupManager:
result, await instance_id.async_get(self.hass)
)
backup = ManagerBackup(
agent_ids=[],
agents={},
addons=result.addons,
backup_id=result.backup_id,
date=result.date,
@ -568,11 +620,12 @@ class BackupManager:
homeassistant_included=result.homeassistant_included,
homeassistant_version=result.homeassistant_version,
name=result.name,
protected=result.protected,
size=result.size,
with_automatic_settings=with_automatic_settings,
)
backup.agent_ids.append(agent_ids[idx])
backup.agents[agent_ids[idx]] = AgentBackupStatus(
protected=result.protected,
size=result.size,
)
return (backup, agent_errors)
@ -688,6 +741,9 @@ class BackupManager:
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
# When receiving a backup, we don't decrypt or encrypt it according to the
# agent settings, we just upload it as is.
password=None,
)
await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors)
@ -855,7 +911,7 @@ class BackupManager:
raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(agent_ids, with_automatic_settings),
self._async_finish_backup(agent_ids, with_automatic_settings, password),
name="backup_manager_finish_backup",
)
if not raise_task_error:
@ -872,7 +928,7 @@ class BackupManager:
return new_backup
async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
@ -906,6 +962,7 @@ class BackupManager:
backup=written_backup.backup,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
password=password,
)
finally:
await written_backup.release_stream()
@ -1269,6 +1326,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Generate a backup."""
manager = self._hass.data[DATA_MANAGER]
agent_config = manager.config.data.agents.get(self._local_agent_id)
if agent_config and not agent_config.protected:
password = None
local_agent_tar_file_path = None
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]

View File

@ -28,7 +28,7 @@ class Folder(StrEnum):
@dataclass(frozen=True, kw_only=True)
class AgentBackup:
class BaseBackup:
"""Base backup class."""
addons: list[AddonInfo]
@ -40,12 +40,6 @@ class AgentBackup:
homeassistant_included: bool
homeassistant_version: str | None # None if homeassistant_included is False
name: str
protected: bool
size: int
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)
def as_frontend_json(self) -> dict:
"""Return a dict representation of this backup for sending to frontend."""
@ -53,6 +47,18 @@ class AgentBackup:
key: val for key, val in asdict(self).items() if key != "extra_metadata"
}
@dataclass(frozen=True, kw_only=True)
class AgentBackup(BaseBackup):
"""Agent backup class."""
protected: bool
size: int
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Create an instance from a JSON serialization."""

View File

@ -48,7 +48,9 @@ class _BackupStore(Store[StoredBackupData]):
data = old_data
if old_major_version == 1:
if old_minor_version < 2:
# Version 1.2 adds configurable backup time and custom days
# Version 1.2 adds per agent settings, configurable backup time
# and custom days
data["config"]["agents"] = {}
data["config"]["schedule"]["time"] = None
if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
data["config"]["schedule"]["days"] = []

View File

@ -3,14 +3,17 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Coroutine
import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
import os
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
from typing import IO, Self, cast
import threading
from typing import IO, Any, Self, cast
import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
@ -30,6 +33,12 @@ class DecryptError(HomeAssistantError):
_message = "Unexpected error during decryption."
class EncryptError(HomeAssistantError):
"""Error during encryption."""
_message = "Unexpected error during encryption."
class UnsupportedSecureTarVersion(DecryptError):
"""Unsupported securetar version."""
@ -179,6 +188,7 @@ class AsyncIteratorWriter:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper."""
self._hass = hass
self._pos: int = 0
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
def __aiter__(self) -> Self:
@ -191,9 +201,14 @@ class AsyncIteratorWriter:
return data
raise StopAsyncIteration
def tell(self) -> int:
"""Return the current position in the iterator."""
return self._pos
def write(self, s: bytes, /) -> int:
"""Write data to the iterator."""
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
self._pos += len(s)
return len(s)
@ -230,9 +245,12 @@ def decrypt_backup(
input_stream: IO[bytes],
output_stream: IO[bytes],
password: str | None,
on_done: Callable[[], None],
on_done: Callable[[Exception | None], None],
minimum_size: int,
nonces: list[bytes],
) -> None:
"""Decrypt a backup."""
error: Exception | None = None
try:
with (
tarfile.open(
@ -245,9 +263,14 @@ def decrypt_backup(
_decrypt_backup(input_tar, output_tar, password)
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
error = err
else:
# Pad the output stream to the requested minimum size
padding = max(minimum_size - output_stream.tell(), 0)
output_stream.write(b"\0" * padding)
finally:
output_stream.write(b"") # Write an empty chunk to signal the end of the stream
on_done()
on_done(error)
def _decrypt_backup(
@ -288,6 +311,180 @@ def _decrypt_backup(
output_tar.addfile(decrypted_obj, decrypted)
def encrypt_backup(
input_stream: IO[bytes],
output_stream: IO[bytes],
password: str | None,
on_done: Callable[[Exception | None], None],
minimum_size: int,
nonces: list[bytes],
) -> None:
"""Encrypt a backup."""
error: Exception | None = None
try:
with (
tarfile.open(
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
) as input_tar,
tarfile.open(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar,
):
_encrypt_backup(input_tar, output_tar, password, nonces)
except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err)
error = err
else:
# Pad the output stream to the requested minimum size
padding = max(minimum_size - output_stream.tell(), 0)
output_stream.write(b"\0" * padding)
finally:
output_stream.write(b"") # Write an empty chunk to signal the end of the stream
on_done(error)
def _encrypt_backup(
input_tar: tarfile.TarFile,
output_tar: tarfile.TarFile,
password: str | None,
nonces: list[bytes],
) -> None:
"""Encrypt a backup."""
inner_tar_idx = 0
for obj in input_tar:
# We compare with PurePath to avoid issues with different path separators,
# for example when backup.json is added as "./backup.json"
if PurePath(obj.name) == PurePath("backup.json"):
# Rewrite the backup.json file to indicate that the backup is encrypted
if not (reader := input_tar.extractfile(obj)):
raise EncryptError
metadata = json_loads_object(reader.read())
metadata["protected"] = True
updated_metadata_b = json.dumps(metadata).encode()
metadata_obj = copy.deepcopy(obj)
metadata_obj.size = len(updated_metadata_b)
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
continue
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
output_tar.addfile(obj, input_tar.extractfile(obj))
continue
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
nonce=nonces[inner_tar_idx],
)
inner_tar_idx += 1
with istf.encrypt(obj) as encrypted:
encrypted_obj = copy.deepcopy(obj)
encrypted_obj.size = encrypted.encrypted_size
output_tar.addfile(encrypted_obj, encrypted)
@dataclass(kw_only=True)
class _CipherWorkerStatus:
done: asyncio.Event
error: Exception | None = None
thread: threading.Thread
class _CipherBackupStreamer:
"""Encrypt or decrypt a backup."""
_cipher_func: Callable[
[
IO[bytes],
IO[bytes],
str | None,
Callable[[Exception | None], None],
int,
list[bytes],
],
None,
]
def __init__(
self,
hass: HomeAssistant,
backup: AgentBackup,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
) -> None:
"""Initialize."""
self._workers: list[_CipherWorkerStatus] = []
self._backup = backup
self._hass = hass
self._open_stream = open_stream
self._password = password
self._nonces: list[bytes] = []
def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup."""
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
def _num_tar_files(self) -> int:
"""Return the number of inner tar files."""
b = self._backup
return len(b.addons) + len(b.folders) + b.homeassistant_included + 1
async def open_stream(self) -> AsyncIterator[bytes]:
"""Open a stream."""
def on_done(error: Exception | None) -> None:
"""Call by the worker thread when it's done."""
worker_status.error = error
self._hass.loop.call_soon_threadsafe(worker_status.done.set)
stream = await self._open_stream()
reader = AsyncIteratorReader(self._hass, stream)
writer = AsyncIteratorWriter(self._hass)
worker = threading.Thread(
target=self._cipher_func,
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
)
worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker)
self._workers.append(worker_status)
worker.start()
return writer
async def wait(self) -> None:
"""Wait for the worker threads to finish."""
await asyncio.gather(*(worker.done.wait() for worker in self._workers))
class DecryptedBackupStreamer(_CipherBackupStreamer):
"""Decrypt a backup."""
_cipher_func = staticmethod(decrypt_backup)
def backup(self) -> AgentBackup:
"""Return the decrypted backup."""
return replace(self._backup, protected=False, size=self.size())
class EncryptedBackupStreamer(_CipherBackupStreamer):
"""Encrypt a backup."""
def __init__(
self,
hass: HomeAssistant,
backup: AgentBackup,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
) -> None:
"""Initialize."""
super().__init__(hass, backup, open_stream, password)
self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())]
_cipher_func = staticmethod(encrypt_backup)
def backup(self) -> AgentBackup:
"""Return the encrypted backup."""
return replace(self._backup, protected=True, size=self.size())
async def receive_file(
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
) -> None:

View File

@ -198,7 +198,7 @@ async def handle_can_decrypt_on_download(
vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool,
vol.Optional("name"): str,
vol.Optional("password"): str,
vol.Optional("password"): vol.Any(str, None),
}
)
@websocket_api.async_response
@ -344,6 +344,7 @@ async def handle_config_info(
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/config/update",
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
vol.Optional("create_backup"): vol.Schema(
{
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),

View File

@ -72,6 +72,7 @@ def mock_create_backup() -> Generator[AsyncMock]:
"""Mock manager create backup."""
mock_written_backup = MagicMock(spec_set=WrittenBackup)
mock_written_backup.backup.backup_id = "abc123"
mock_written_backup.backup.protected = False
mock_written_backup.open_stream = AsyncMock()
mock_written_backup.release_stream = AsyncMock()
fut: Future[MagicMock] = Future()

View File

@ -62,9 +62,12 @@
'version': '1.0.0',
}),
]),
'agent_ids': list([
'backup.local',
]),
'agents': dict({
'backup.local': dict({
'protected': False,
'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
'date': '1970-01-01T00:00:00.000Z',
@ -77,8 +80,6 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
'protected': False,
'size': 0,
'with_automatic_settings': True,
}),
]),

View File

@ -11,6 +11,8 @@
}),
]),
'config': dict({
'agents': dict({
}),
'create_backup': dict({
'agent_ids': list([
]),
@ -53,6 +55,8 @@
}),
]),
'config': dict({
'agents': dict({
}),
'create_backup': dict({
'agent_ids': list([
'test-agent',
@ -96,6 +100,11 @@
}),
]),
'config': dict({
'agents': dict({
'test.remote': dict({
'protected': True,
}),
}),
'create_backup': dict({
'agent_ids': list([
]),
@ -138,6 +147,11 @@
}),
]),
'config': dict({
'agents': dict({
'test.remote': dict({
'protected': True,
}),
}),
'create_backup': dict({
'agent_ids': list([
'test-agent',

File diff suppressed because it is too large Load Diff

View File

@ -487,11 +487,13 @@ async def test_initiate_backup(
result = await ws_client.receive_json()
backup_data = result["result"]["backup"]
backup_agent_ids = backup_data.pop("agent_ids")
assert backup_agent_ids == agent_ids
assert backup_data == {
"addons": [],
"agents": {
agent_id: {"protected": bool(password), "size": ANY}
for agent_id in agent_ids
},
"backup_id": backup_id,
"database_included": include_database,
"date": ANY,
@ -500,8 +502,6 @@ async def test_initiate_backup(
"homeassistant_included": True,
"homeassistant_version": "2025.1.0",
"name": name,
"protected": bool(password),
"size": ANY,
"with_automatic_settings": False,
}
@ -543,9 +543,7 @@ async def test_initiate_backup_with_agent_error(
"version": "1.0.0",
},
],
"agent_ids": [
"test.remote",
],
"agents": {"test.remote": {"protected": False, "size": 0}},
"backup_id": "backup1",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
@ -557,15 +555,11 @@ async def test_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0,
"with_automatic_settings": True,
},
{
"addons": [],
"agent_ids": [
"test.remote",
],
"agents": {"test.remote": {"protected": False, "size": 1}},
"backup_id": "backup2",
"database_included": False,
"date": "1980-01-01T00:00:00.000Z",
@ -577,8 +571,6 @@ async def test_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test 2",
"protected": False,
"size": 1,
"with_automatic_settings": None,
},
{
@ -589,9 +581,7 @@ async def test_initiate_backup_with_agent_error(
"version": "1.0.0",
},
],
"agent_ids": [
"test.remote",
],
"agents": {"test.remote": {"protected": False, "size": 0}},
"backup_id": "backup3",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
@ -603,8 +593,6 @@ async def test_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0,
"with_automatic_settings": True,
},
]
@ -714,7 +702,7 @@ async def test_initiate_backup_with_agent_error(
new_expected_backup_data = {
"addons": [],
"agent_ids": ["backup.local"],
"agents": {"backup.local": {"protected": False, "size": 123}},
"backup_id": "abc123",
"database_included": True,
"date": ANY,
@ -723,8 +711,6 @@ async def test_initiate_backup_with_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2025.1.0",
"name": "Custom backup 2025.1.0",
"protected": False,
"size": 123,
"with_automatic_settings": False,
}
@ -1633,9 +1619,7 @@ async def test_receive_backup_agent_error(
"version": "1.0.0",
},
],
"agent_ids": [
"test.remote",
],
"agents": {"test.remote": {"protected": False, "size": 0}},
"backup_id": "backup1",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
@ -1647,15 +1631,11 @@ async def test_receive_backup_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0,
"with_automatic_settings": True,
},
{
"addons": [],
"agent_ids": [
"test.remote",
],
"agents": {"test.remote": {"protected": False, "size": 1}},
"backup_id": "backup2",
"database_included": False,
"date": "1980-01-01T00:00:00.000Z",
@ -1667,8 +1647,6 @@ async def test_receive_backup_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test 2",
"protected": False,
"size": 1,
"with_automatic_settings": None,
},
{
@ -1679,9 +1657,7 @@ async def test_receive_backup_agent_error(
"version": "1.0.0",
},
],
"agent_ids": [
"test.remote",
],
"agents": {"test.remote": {"protected": False, "size": 0}},
"backup_id": "backup3",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
@ -1693,8 +1669,6 @@ async def test_receive_backup_agent_error(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0,
"with_automatic_settings": True,
},
]
@ -2936,3 +2910,220 @@ async def test_restore_backup_file_error(
assert open_mock.return_value.close.call_count == close_call_count
assert mocked_write_text.call_count == write_text_call_count
assert mocked_service_call.call_count == 0
@pytest.mark.parametrize(
("commands", "password", "protected_backup"),
[
(
[],
None,
{"backup.local": False, "test.remote": False},
),
(
[],
"hunter2",
{"backup.local": True, "test.remote": True},
),
(
[
{
"type": "backup/config/update",
"agents": {
"backup.local": {"protected": False},
"test.remote": {"protected": False},
},
}
],
"hunter2",
{"backup.local": False, "test.remote": False},
),
(
[
{
"type": "backup/config/update",
"agents": {
"backup.local": {"protected": False},
"test.remote": {"protected": True},
},
}
],
"hunter2",
{"backup.local": False, "test.remote": True},
),
(
[
{
"type": "backup/config/update",
"agents": {
"backup.local": {"protected": True},
"test.remote": {"protected": False},
},
}
],
"hunter2",
{"backup.local": True, "test.remote": False},
),
(
[
{
"type": "backup/config/update",
"agents": {
"backup.local": {"protected": True},
"test.remote": {"protected": True},
},
}
],
"hunter2",
{"backup.local": True, "test.remote": True},
),
(
[
{
"type": "backup/config/update",
"agents": {
"backup.local": {"protected": False},
"test.remote": {"protected": True},
},
}
],
None,
{"backup.local": False, "test.remote": False},
),
],
)
@pytest.mark.usefixtures("mock_backup_generation")
async def test_initiate_backup_per_agent_encryption(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
path_glob: MagicMock,
commands: dict[str, Any],
password: str | None,
protected_backup: dict[str, bool],
) -> None:
"""Test generate backup where encryption is selectively set on agents."""
agent_ids = ["backup.local", "test.remote"]
local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
remote_agent = BackupAgentTest("remote", backups=[])
with patch(
"homeassistant.components.backup.backup.async_get_backup_agents"
) as core_get_backup_agents:
core_get_backup_agents.return_value = [local_agent]
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
spec_set=BackupAgentPlatformProtocol,
),
)
ws_client = await hass_ws_client(hass)
path_glob.return_value = []
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"backups": [],
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"next_automatic_backup": None,
"next_automatic_backup_additional": False,
}
for command in commands:
await ws_client.send_json_auto_id(command)
result = await ws_client.receive_json()
assert result["success"] is True
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
result = await ws_client.receive_json()
assert result["success"] is True
with (
patch("pathlib.Path.open", mock_open(read_data=b"test")),
):
await ws_client.send_json_auto_id(
{
"type": "backup/generate",
"agent_ids": agent_ids,
"password": password,
"name": "test",
}
)
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["success"] is True
backup_id = result["result"]["backup_job_id"]
assert backup_id == generate_backup_id.return_value
await hass.async_block_till_done()
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": CreateBackupStage.UPLOAD_TO_AGENTS,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": None,
"state": CreateBackupState.COMPLETED,
}
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
await ws_client.send_json_auto_id(
{"type": "backup/details", "backup_id": backup_id}
)
result = await ws_client.receive_json()
backup_data = result["result"]["backup"]
assert backup_data == {
"addons": [],
"agents": {
agent_id: {"protected": protected_backup[agent_id], "size": ANY}
for agent_id in agent_ids
},
"backup_id": backup_id,
"database_included": True,
"date": ANY,
"failed_agent_ids": [],
"folders": [],
"homeassistant_included": True,
"homeassistant_version": "2025.1.0",
"name": "test",
"with_automatic_settings": False,
}

View File

@ -66,6 +66,7 @@ def mock_delay_save() -> Generator[None]:
}
],
"config": {
"agents": {"test.remote": {"protected": True}},
"create_backup": {
"agent_ids": [],
"include_addons": None,

View File

@ -2,13 +2,24 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import dataclasses
import tarfile
from unittest.mock import Mock, patch
import pytest
import securetar
from homeassistant.components.backup import AddonInfo, AgentBackup, Folder
from homeassistant.components.backup.util import read_backup, validate_password
from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder
from homeassistant.components.backup.util import (
DecryptedBackupStreamer,
EncryptedBackupStreamer,
read_backup,
validate_password,
)
from homeassistant.core import HomeAssistant
from tests.common import get_fixture_path
@pytest.mark.parametrize(
@ -130,3 +141,246 @@ def test_validate_password_no_homeassistant() -> None:
KeyError
)
assert validate_password(mock_path, "hunter2") is False
async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None:
"""Test the decrypted backup streamer."""
decrypted_backup_path = get_fixture_path(
"test_backups/c0cb53bd.tar.decrypted", DOMAIN
)
encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
backup = AgentBackup(
addons=["addon_1", "addon_2"],
backup_id="1234",
date="2024-12-02T07:23:58.261875-05:00",
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=True,
homeassistant_version="2024.12.0.dev0",
name="test",
protected=True,
size=encrypted_backup_path.stat().st_size,
)
expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding
async def send_backup() -> AsyncIterator[bytes]:
f = encrypted_backup_path.open("rb")
while chunk := f.read(1024):
yield chunk
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2")
assert decryptor.backup() == dataclasses.replace(
backup, protected=False, size=backup.size + len(expected_padding)
)
decrypted_stream = await decryptor.open_stream()
decrypted_output = b""
async for chunk in decrypted_stream:
decrypted_output += chunk
await decryptor.wait()
# Expect the output to match the stored decrypted backup file, with additional
# padding.
decrypted_backup_data = decrypted_backup_path.read_bytes()
assert decrypted_output == decrypted_backup_data + expected_padding
async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None:
"""Test the decrypted backup streamer with wrong password."""
encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
backup = AgentBackup(
addons=["addon_1", "addon_2"],
backup_id="1234",
date="2024-12-02T07:23:58.261875-05:00",
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=True,
homeassistant_version="2024.12.0.dev0",
name="test",
protected=True,
size=encrypted_backup_path.stat().st_size,
)
async def send_backup() -> AsyncIterator[bytes]:
f = encrypted_backup_path.open("rb")
while chunk := f.read(1024):
yield chunk
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "wrong_password")
decrypted_stream = await decryptor.open_stream()
async for _ in decrypted_stream:
pass
await decryptor.wait()
assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError)
async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None:
"""Test the encrypted backup streamer."""
decrypted_backup_path = get_fixture_path(
"test_backups/c0cb53bd.tar.decrypted", DOMAIN
)
encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
backup = AgentBackup(
addons=["addon_1", "addon_2"],
backup_id="1234",
date="2024-12-02T07:23:58.261875-05:00",
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=True,
homeassistant_version="2024.12.0.dev0",
name="test",
protected=False,
size=decrypted_backup_path.stat().st_size,
)
expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding
async def send_backup() -> AsyncIterator[bytes]:
f = decrypted_backup_path.open("rb")
while chunk := f.read(1024):
yield chunk
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
# Patch os.urandom to return values matching the nonce used in the encrypted
# test backup. The backup has three inner tar files, but we need an extra nonce
# for a future planned supervisor.tar.
with patch("os.urandom") as mock_randbytes:
mock_randbytes.side_effect = (
bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"),
bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"),
bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"),
bytes.fromhex("00000000000000000000000000000000"),
)
encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
assert encryptor.backup() == dataclasses.replace(
backup, protected=True, size=backup.size + len(expected_padding)
)
encrypted_stream = await encryptor.open_stream()
encrypted_output = b""
async for chunk in encrypted_stream:
encrypted_output += chunk
await encryptor.wait()
# Expect the output to match the stored encrypted backup file, with additional
# padding.
encrypted_backup_data = encrypted_backup_path.read_bytes()
assert encrypted_output == encrypted_backup_data + expected_padding
async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None:
"""Test the encrypted backup streamer."""
decrypted_backup_path = get_fixture_path(
"test_backups/c0cb53bd.tar.decrypted", DOMAIN
)
encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
backup = AgentBackup(
addons=["addon_1", "addon_2"],
backup_id="1234",
date="2024-12-02T07:23:58.261875-05:00",
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=True,
homeassistant_version="2024.12.0.dev0",
name="test",
protected=False,
size=decrypted_backup_path.stat().st_size,
)
async def send_backup() -> AsyncIterator[bytes]:
f = decrypted_backup_path.open("rb")
while chunk := f.read(1024):
yield chunk
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
encryptor1 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
encryptor2 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
async def read_stream(stream: AsyncIterator[bytes]) -> bytes:
output = b""
async for chunk in stream:
output += chunk
return output
# When reading twice from the same streamer, the same nonce is used.
encrypted_output1 = await read_stream(await encryptor1.open_stream())
encrypted_output2 = await read_stream(await encryptor1.open_stream())
assert encrypted_output1 == encrypted_output2
encrypted_output3 = await read_stream(await encryptor2.open_stream())
encrypted_output4 = await read_stream(await encryptor2.open_stream())
assert encrypted_output3 == encrypted_output4
# Wait for workers to terminate
await encryptor1.wait()
await encryptor2.wait()
# Output from the two streames should differ but have the same length.
assert encrypted_output1 != encrypted_output3
assert len(encrypted_output1) == len(encrypted_output3)
# Expect the output length to match the stored encrypted backup file, with
# additional padding.
encrypted_backup_data = encrypted_backup_path.read_bytes()
# 4 x 10240 byte of padding
assert len(encrypted_output1) == len(encrypted_backup_data) + 40960
assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data
async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None:
"""Test the encrypted backup streamer."""
decrypted_backup_path = get_fixture_path(
"test_backups/c0cb53bd.tar.decrypted", DOMAIN
)
backup = AgentBackup(
addons=["addon_1", "addon_2"],
backup_id="1234",
date="2024-12-02T07:23:58.261875-05:00",
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=True,
homeassistant_version="2024.12.0.dev0",
name="test",
protected=False,
size=decrypted_backup_path.stat().st_size,
)
async def send_backup() -> AsyncIterator[bytes]:
f = decrypted_backup_path.open("rb")
while chunk := f.read(1024):
yield chunk
async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
# Patch os.urandom to return values matching the nonce used in the encrypted
# test backup. The backup has three inner tar files, but we need an extra nonce
# for a future planned supervisor.tar.
encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2")
with patch(
"homeassistant.components.backup.util.tarfile.open",
side_effect=tarfile.TarError,
):
encrypted_stream = await encryptor.open_stream()
async for _ in encrypted_stream:
pass
# Expect the output to match the stored encrypted backup file, with additional
# padding.
await encryptor.wait()
assert isinstance(encryptor._workers[0].error, tarfile.TarError)

View File

@ -56,6 +56,7 @@ BACKUP_CALL = call(
DEFAULT_STORAGE_DATA: dict[str, Any] = {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": [],
"include_addons": None,
@ -587,6 +588,8 @@ async def test_generate_with_default_settings_calls_create(
last_completed_automatic_backup: str,
) -> None:
"""Test backup/generate_with_automatic_settings calls async_initiate_backup."""
created_backup: MagicMock = create_backup.return_value[1].result().backup
created_backup.protected = create_backup_settings["password"] is not None
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")
@ -913,6 +916,7 @@ async def test_agents_info(
"data": {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
@ -943,6 +947,7 @@ async def test_agents_info(
"data": {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": None,
@ -973,6 +978,7 @@ async def test_agents_info(
"data": {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": None,
@ -1003,6 +1009,7 @@ async def test_agents_info(
"data": {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": None,
@ -1033,6 +1040,7 @@ async def test_agents_info(
"data": {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": None,
@ -1063,6 +1071,41 @@ async def test_agents_info(
"data": {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": None,
"include_all_addons": False,
"include_database": False,
"include_folders": None,
"name": None,
"password": None,
},
"retention": {"copies": None, "days": None},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"schedule": {
"days": ["mon", "sun"],
"recurrence": "custom_days",
"state": "never",
"time": None,
},
},
},
"key": DOMAIN,
"version": store.STORAGE_VERSION,
"minor_version": store.STORAGE_VERSION_MINOR,
},
},
{
"backup": {
"data": {
"backups": [],
"config": {
"agents": {
"test-agent1": {"protected": True},
"test-agent2": {"protected": False},
},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": None,
@ -1115,80 +1158,130 @@ async def test_config_info(
@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups")
@pytest.mark.parametrize(
"command",
"commands",
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 7},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"recurrence": "daily", "time": "06:00"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"days": ["mon"], "recurrence": "custom_days"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"recurrence": "never"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"},
},
{
"type": "backup/config/update",
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
"include_folders": ["media"],
"name": "test-name",
"password": "test-password",
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 7},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"recurrence": "daily", "time": "06:00"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"days": ["mon"], "recurrence": "custom_days"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"recurrence": "never"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
"include_folders": ["media"],
"name": "test-name",
"password": "test-password",
},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": 3, "days": 7},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": None},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": 3, "days": None},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 7},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": 3},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"days": 7},
"schedule": {"recurrence": "daily"},
}
],
[
{
"type": "backup/config/update",
"agents": {
"test-agent1": {"protected": True},
"test-agent2": {"protected": False},
},
}
],
[
# Test we can update AgentConfig
{
"type": "backup/config/update",
"agents": {
"test-agent1": {"protected": True},
"test-agent2": {"protected": False},
},
},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": 3, "days": 7},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": None},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": 3, "days": None},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": None, "days": 7},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"copies": 3},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"create_backup": {"agent_ids": ["test-agent"]},
"retention": {"days": 7},
"schedule": {"recurrence": "daily"},
},
{
"type": "backup/config/update",
"agents": {
"test-agent1": {"protected": False},
"test-agent2": {"protected": True},
},
},
],
],
)
@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600))
@ -1197,7 +1290,7 @@ async def test_config_update(
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
command: dict[str, Any],
commands: dict[str, Any],
hass_storage: dict[str, Any],
) -> None:
"""Test updating the backup config."""
@ -1211,14 +1304,14 @@ async def test_config_update(
await client.send_json_auto_id({"type": "backup/config/info"})
assert await client.receive_json() == snapshot
await client.send_json_auto_id(command)
result = await client.receive_json()
for command in commands:
await client.send_json_auto_id(command)
result = await client.receive_json()
assert result["success"]
assert result["success"]
await client.send_json_auto_id({"type": "backup/config/info"})
assert await client.receive_json() == snapshot
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/config/info"})
assert await client.receive_json() == snapshot
await hass.async_block_till_done()
# Trigger store write
freezer.tick(60)
@ -1274,6 +1367,10 @@ async def test_config_update(
"type": "backup/config/update",
"create_backup": {"include_folders": ["media", "media"]},
},
{
"type": "backup/config/update",
"agents": {"test-agent1": {"favorite": True}},
},
],
)
async def test_config_update_errors(
@ -1600,10 +1697,14 @@ async def test_config_schedule_logic(
create_backup_side_effect: list[Exception | None] | None,
) -> None:
"""Test config schedule logic."""
created_backup: MagicMock = create_backup.return_value[1].result().backup
created_backup.protected = True
client = await hass_ws_client(hass)
storage_data = {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test.test-agent"],
"include_addons": ["test-addon"],
@ -2057,10 +2158,14 @@ async def test_config_retention_copies_logic(
delete_args_list: Any,
) -> None:
"""Test config backup retention copies logic."""
created_backup: MagicMock = create_backup.return_value[1].result().backup
created_backup.protected = True
client = await hass_ws_client(hass)
storage_data = {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
@ -2320,10 +2425,14 @@ async def test_config_retention_copies_logic_manual_backup(
delete_args_list: Any,
) -> None:
"""Test config backup retention copies logic for manual backup."""
created_backup: MagicMock = create_backup.return_value[1].result().backup
created_backup.protected = True
client = await hass_ws_client(hass)
storage_data = {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],
@ -2750,6 +2859,7 @@ async def test_config_retention_days_logic(
storage_data = {
"backups": [],
"config": {
"agents": {},
"create_backup": {
"agent_ids": ["test-agent"],
"include_addons": ["test-addon"],

View File

@ -170,6 +170,7 @@ async def test_agents_list_backups(
assert response["result"]["backups"] == [
{
"addons": [],
"agents": {"cloud.cloud": {"protected": False, "size": 34519040}},
"backup_id": "23e64aec",
"date": "2024-11-22T11:48:48.727189+01:00",
"database_included": True,
@ -177,9 +178,6 @@ async def test_agents_list_backups(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0.dev0",
"name": "Core 2024.12.0.dev0",
"protected": False,
"size": 34519040,
"agent_ids": ["cloud.cloud"],
"failed_agent_ids": [],
"with_automatic_settings": None,
}
@ -219,6 +217,7 @@ async def test_agents_list_backups_fail_cloud(
"23e64aec",
{
"addons": [],
"agents": {"cloud.cloud": {"protected": False, "size": 34519040}},
"backup_id": "23e64aec",
"date": "2024-11-22T11:48:48.727189+01:00",
"database_included": True,
@ -226,9 +225,6 @@ async def test_agents_list_backups_fail_cloud(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0.dev0",
"name": "Core 2024.12.0.dev0",
"protected": False,
"size": 34519040,
"agent_ids": ["cloud.cloud"],
"failed_agent_ids": [],
"with_automatic_settings": None,
},

View File

@ -43,6 +43,7 @@ TEST_AGENT_BACKUP = AgentBackup(
)
TEST_AGENT_BACKUP_RESULT = {
"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}],
"agents": {TEST_AGENT_ID: {"protected": False, "size": 987}},
"backup_id": "test-backup",
"database_included": True,
"date": "2025-01-01T01:23:45.678Z",
@ -50,9 +51,6 @@ TEST_AGENT_BACKUP_RESULT = {
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 987,
"agent_ids": [TEST_AGENT_ID],
"failed_agent_ids": [],
"with_automatic_settings": None,
}

View File

@ -407,7 +407,7 @@ async def test_agent_info(
"addons": [
{"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"}
],
"agent_ids": ["hassio.local"],
"agents": {"hassio.local": {"protected": False, "size": 1048576}},
"backup_id": "abc123",
"database_included": True,
"date": "1970-01-01T00:00:00+00:00",
@ -416,8 +416,6 @@ async def test_agent_info(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 1048576,
"with_automatic_settings": None,
},
),
@ -428,7 +426,7 @@ async def test_agent_info(
"addons": [
{"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"}
],
"agent_ids": ["hassio.local"],
"agents": {"hassio.local": {"protected": False, "size": 1048576}},
"backup_id": "abc123",
"database_included": False,
"date": "1970-01-01T00:00:00+00:00",
@ -437,8 +435,6 @@ async def test_agent_info(
"homeassistant_included": False,
"homeassistant_version": None,
"name": "Test",
"protected": False,
"size": 1048576,
"with_automatic_settings": None,
},
),

View File

@ -102,7 +102,7 @@ async def test_agents_list_backups(
assert response["result"]["backups"] == [
{
"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}],
"agent_ids": ["kitchen_sink.syncer"],
"agents": {"kitchen_sink.syncer": {"protected": False, "size": 1234}},
"backup_id": "abc123",
"database_included": False,
"date": "1970-01-01T00:00:00Z",
@ -111,8 +111,6 @@ async def test_agents_list_backups(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Kitchen sink syncer",
"protected": False,
"size": 1234,
"with_automatic_settings": None,
}
]
@ -185,7 +183,7 @@ async def test_agents_upload(
assert len(backup_list) == 2
assert backup_list[1] == {
"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}],
"agent_ids": ["kitchen_sink.syncer"],
"agents": {"kitchen_sink.syncer": {"protected": False, "size": 0.0}},
"backup_id": "test-backup",
"database_included": True,
"date": "1970-01-01T00:00:00.000Z",
@ -194,8 +192,6 @@ async def test_agents_upload(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0",
"name": "Test",
"protected": False,
"size": 0.0,
"with_automatic_settings": False,
}

View File

@ -81,6 +81,9 @@ async def test_agents_list_backups(
assert response["result"]["backups"] == [
{
"addons": [],
"agents": {
"onedrive.mock_drive_id": {"protected": False, "size": 34519040}
},
"backup_id": "23e64aec",
"date": "2024-11-22T11:48:48.727189+01:00",
"database_included": True,
@ -88,9 +91,6 @@ async def test_agents_list_backups(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0.dev0",
"name": "Core 2024.12.0.dev0",
"protected": False,
"size": 34519040,
"agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"],
"failed_agent_ids": [],
"with_automatic_settings": None,
}
@ -117,6 +117,12 @@ async def test_agents_get_backup(
assert response["result"]["agent_errors"] == {}
assert response["result"]["backup"] == {
"addons": [],
"agents": {
f"{DOMAIN}.{mock_config_entry.unique_id}": {
"protected": False,
"size": 34519040,
}
},
"backup_id": "23e64aec",
"date": "2024-11-22T11:48:48.727189+01:00",
"database_included": True,
@ -124,9 +130,6 @@ async def test_agents_get_backup(
"homeassistant_included": True,
"homeassistant_version": "2024.12.0.dev0",
"name": "Core 2024.12.0.dev0",
"protected": False,
"size": 34519040,
"agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"],
"failed_agent_ids": [],
"with_automatic_settings": None,
}

View File

@ -290,6 +290,12 @@ async def test_agents_list_backups(
assert response["result"]["backups"] == [
{
"addons": [],
"agents": {
"synology_dsm.mocked_syno_dsm_entry": {
"protected": True,
"size": 13916160,
}
},
"backup_id": "abcd12ef",
"date": "2025-01-09T20:14:35.457323+01:00",
"database_included": True,
@ -297,9 +303,6 @@ async def test_agents_list_backups(
"homeassistant_included": True,
"homeassistant_version": "2025.2.0.dev0",
"name": "Automatic backup 2025.2.0.dev0",
"protected": True,
"size": 13916160,
"agent_ids": ["synology_dsm.mocked_syno_dsm_entry"],
"failed_agent_ids": [],
"with_automatic_settings": None,
}
@ -355,6 +358,12 @@ async def test_agents_list_backups_disabled_filestation(
"abcd12ef",
{
"addons": [],
"agents": {
"synology_dsm.mocked_syno_dsm_entry": {
"protected": True,
"size": 13916160,
}
},
"backup_id": "abcd12ef",
"date": "2025-01-09T20:14:35.457323+01:00",
"database_included": True,
@ -362,9 +371,6 @@ async def test_agents_list_backups_disabled_filestation(
"homeassistant_included": True,
"homeassistant_version": "2025.2.0.dev0",
"name": "Automatic backup 2025.2.0.dev0",
"protected": True,
"size": 13916160,
"agent_ids": ["synology_dsm.mocked_syno_dsm_entry"],
"failed_agent_ids": [],
"with_automatic_settings": None,
},