mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 17:27:52 +00:00
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:
parent
3e513dda62
commit
9a687e7f94
@ -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."""
|
||||
|
@ -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()
|
||||
|
@ -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]
|
||||
|
@ -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."""
|
||||
|
@ -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"] = []
|
||||
|
@ -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:
|
||||
|
@ -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()),
|
||||
|
@ -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()
|
||||
|
Binary file not shown.
@ -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,
|
||||
}),
|
||||
]),
|
||||
|
@ -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
@ -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,
|
||||
}
|
||||
|
@ -66,6 +66,7 @@ def mock_delay_save() -> Generator[None]:
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"agents": {"test.remote": {"protected": True}},
|
||||
"create_backup": {
|
||||
"agent_ids": [],
|
||||
"include_addons": None,
|
||||
|
@ -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)
|
||||
|
@ -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"],
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
),
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user