Skip to content

Commit 9c858cd

Browse files
committed
Test cases and small fixes identified
1 parent fcf72c1 commit 9c858cd

File tree

15 files changed

+349
-43
lines changed

15 files changed

+349
-43
lines changed

supervisor/api/backups.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
1919
from ..const import (
2020
ATTR_ADDONS,
21-
ATTR_BACKUP,
2221
ATTR_BACKUPS,
2322
ATTR_COMPRESSED,
2423
ATTR_CONTENT,
@@ -55,7 +54,7 @@
5554
_LOGGER: logging.Logger = logging.getLogger(__name__)
5655

5756
RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
58-
RE_FILENAME = re.compile(r"^[^\\\/]+")
57+
RE_BACKUP_FILENAME = re.compile(r"^[^\\\/]+\.tar$")
5958

6059
# Backwards compatible
6160
# Remove: 2022.08
@@ -109,12 +108,8 @@
109108
)
110109
SCHEMA_RELOAD = vol.Schema(
111110
{
112-
vol.Optional(ATTR_BACKUP, default={}): vol.Schema(
113-
{
114-
vol.Required(ATTR_LOCATION): vol.Maybe(str),
115-
vol.Required(ATTR_FILENAME): vol.Match(RE_FILENAME),
116-
}
117-
)
111+
vol.Inclusive(ATTR_LOCATION, "file"): vol.Maybe(str),
112+
vol.Inclusive(ATTR_FILENAME, "file"): vol.Match(RE_BACKUP_FILENAME),
118113
}
119114
)
120115

@@ -185,7 +180,8 @@ async def options(self, request):
185180
async def reload(self, request: web.Request):
186181
"""Reload backup list."""
187182
body = await api_validate(SCHEMA_RELOAD, request)
188-
backup = self._location_to_mount(body[ATTR_BACKUP])
183+
self._validate_cloud_backup_location(request, body.get(ATTR_LOCATION))
184+
backup = self._location_to_mount(body)
189185

190186
return await asyncio.shield(self.sys_backups.reload(**backup))
191187

supervisor/api/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
ATTR_USAGE = "usage"
6969
ATTR_USE_NTP = "use_ntp"
7070
ATTR_USERS = "users"
71+
ATTR_USER_PATH = "user_path"
7172
ATTR_VENDOR = "vendor"
7273
ATTR_VIRTUALIZATION = "virtualization"
7374

supervisor/api/mounts.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage
1212
from ..mounts.mount import Mount
1313
from ..mounts.validate import SCHEMA_MOUNT_CONFIG
14-
from .const import ATTR_MOUNTS
14+
from .const import ATTR_MOUNTS, ATTR_USER_PATH
1515
from .utils import api_process, api_validate
1616

1717
SCHEMA_OPTIONS = vol.Schema(
@@ -32,7 +32,11 @@ async def info(self, request: web.Request) -> dict[str, Any]:
3232
if self.sys_mounts.default_backup_mount
3333
else None,
3434
ATTR_MOUNTS: [
35-
mount.to_dict() | {ATTR_STATE: mount.state}
35+
mount.to_dict()
36+
| {
37+
ATTR_STATE: mount.state,
38+
ATTR_USER_PATH: mount.container_where.as_posix(),
39+
}
3640
for mount in self.sys_mounts.mounts
3741
],
3842
}

supervisor/backups/backup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ def container_path(self) -> PurePath | None:
212212
path_map: dict[Path, PurePath] = {
213213
self.sys_config.path_backup: PATH_BACKUP,
214214
self.sys_config.path_core_backup: PATH_CLOUD_BACKUP,
215+
} | {
216+
mount.local_where: mount.container_where
217+
for mount in self.sys_mounts.backup_mounts
215218
}
216219
for source, target in path_map.items():
217220
if self.tarfile.is_relative_to(source):

supervisor/backups/manager.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
ATTR_DATA,
1515
ATTR_DAYS_UNTIL_STALE,
1616
ATTR_JOB_ID,
17-
ATTR_LOCATION,
17+
ATTR_PATH,
1818
ATTR_SLUG,
1919
ATTR_TYPE,
2020
FILE_HASSIO_BACKUPS,
@@ -84,11 +84,10 @@ def backup_locations(self) -> dict[str | None, Path]:
8484
return {
8585
None: self.sys_config.path_backup,
8686
LOCATION_CLOUD_BACKUP: self.sys_config.path_core_backup,
87-
**{
88-
mount.name: mount.local_where
89-
for mount in self.sys_mounts.backup_mounts
90-
if mount.state == UnitActiveState.ACTIVE
91-
},
87+
} | {
88+
mount.name: mount.local_where
89+
for mount in self.sys_mounts.backup_mounts
90+
if mount.state == UnitActiveState.ACTIVE
9291
}
9392

9493
def get(self, slug: str) -> Backup:
@@ -241,7 +240,8 @@ async def _load_backup(location: str | None, tar_file: Path) -> bool:
241240

242241
if location != DEFAULT and filename:
243242
return await _load_backup(
244-
location, self._get_base_path(location) / filename
243+
self._get_location_name(location),
244+
self._get_base_path(location) / filename,
245245
)
246246

247247
self._backups = {}
@@ -358,13 +358,13 @@ async def _do_backup(
358358
return None
359359
else:
360360
self._backups[backup.slug] = backup
361-
self.sys_homeassistant.websocket.async_send_message(
361+
await self.sys_homeassistant.websocket.async_send_message(
362362
{
363363
ATTR_TYPE: WSType.BACKUP_COMPLETE,
364364
ATTR_DATA: {
365365
ATTR_JOB_ID: self.sys_jobs.current.uuid,
366366
ATTR_SLUG: backup.slug,
367-
ATTR_LOCATION: backup.location,
367+
ATTR_PATH: backup.container_path.as_posix(),
368368
},
369369
}
370370
)

supervisor/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@
260260
ATTR_PANELS = "panels"
261261
ATTR_PARENT = "parent"
262262
ATTR_PASSWORD = "password"
263+
ATTR_PATH = "path"
263264
ATTR_PLUGINS = "plugins"
264265
ATTR_PORT = "port"
265266
ATTR_PORTS = "ports"

supervisor/docker/homeassistant.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def mounts(self) -> list[Mount]:
135135
Mount(
136136
type=MountType.BIND,
137137
source=self.sys_config.path_extern_backup.as_posix(),
138-
target=PATH_BACKUP,
138+
target=PATH_BACKUP.as_posix(),
139139
read_only=False,
140140
propagation=PropagationMode.RSLAVE.value,
141141
),

supervisor/mounts/mount.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -164,24 +164,19 @@ def local_where(self) -> Path | None:
164164
else None
165165
)
166166

167-
@cached_property
168-
def container_where(self) -> Path | None:
167+
@property
168+
def container_where(self) -> PurePath | None:
169169
"""Return where this is made available in managed containers (core, addons, etc.).
170170
171-
This returns none if 'local_where' is none or not a place mapped into other containers.
171+
This returns none if it is not made available in managed containers.
172172
"""
173-
if not (local_where := self.local_where):
174-
return None
175-
176-
path_map: dict[Path, PurePath] = {
177-
self.sys_config.path_backup: PATH_BACKUP,
178-
self.sys_config.path_media: PATH_MEDIA,
179-
self.sys_config.path_share: PATH_SHARE,
180-
}
181-
for source, target in path_map.items():
182-
if local_where.is_relative_to(source):
183-
return target / local_where.relative_to(source)
184-
173+
match self.usage:
174+
case MountUsage.BACKUP:
175+
return PurePath(PATH_BACKUP, self.name)
176+
case MountUsage.MEDIA:
177+
return PurePath(PATH_MEDIA, self.name)
178+
case MountUsage.SHARE:
179+
return PurePath(PATH_SHARE, self.name)
185180
return None
186181

187182
@property

tests/api/test_backups.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
from pathlib import Path, PurePath
5+
from shutil import copy
56
from typing import Any
67
from unittest.mock import ANY, AsyncMock, PropertyMock, patch
78

@@ -19,6 +20,9 @@
1920
from supervisor.mounts.mount import Mount
2021
from supervisor.supervisor import Supervisor
2122

23+
from tests.common import get_fixture_path
24+
from tests.const import TEST_ADDON_SLUG
25+
2226

2327
async def test_info(api_client, coresys: CoreSys, mock_full_backup: Backup):
2428
"""Test info endpoint."""
@@ -467,3 +471,120 @@ async def test_restore_immediate_errors(
467471
)
468472
assert resp.status == 400
469473
assert "No Home Assistant" in (await resp.json())["message"]
474+
475+
476+
@pytest.mark.parametrize(
477+
("folder", "location"), [("backup", None), ("core/backup", ".cloud_backup")]
478+
)
479+
async def test_reload(
480+
request: pytest.FixtureRequest,
481+
api_client: TestClient,
482+
coresys: CoreSys,
483+
tmp_supervisor_data: Path,
484+
folder: str,
485+
location: str | None,
486+
):
487+
"""Test backups reload."""
488+
assert not coresys.backups.list_backups
489+
490+
backup_file = get_fixture_path("backup_example.tar")
491+
copy(backup_file, tmp_supervisor_data / folder)
492+
493+
resp = await api_client.post("/backups/reload")
494+
assert resp.status == 200
495+
496+
assert len(coresys.backups.list_backups) == 1
497+
assert (backup := coresys.backups.get("7fed74c8"))
498+
assert backup.location == location
499+
assert backup.locations == [location]
500+
501+
502+
@pytest.mark.parametrize(
503+
("folder", "location"), [("backup", None), ("core/backup", ".cloud_backup")]
504+
)
505+
async def test_partial_reload(
506+
request: pytest.FixtureRequest,
507+
api_client: TestClient,
508+
coresys: CoreSys,
509+
tmp_supervisor_data: Path,
510+
folder: str,
511+
location: str | None,
512+
):
513+
"""Test partial backups reload."""
514+
assert not coresys.backups.list_backups
515+
516+
backup_file = get_fixture_path("backup_example.tar")
517+
copy(backup_file, tmp_supervisor_data / folder)
518+
519+
resp = await api_client.post(
520+
"/backups/reload", json={"location": location, "filename": "backup_example.tar"}
521+
)
522+
assert resp.status == 200
523+
524+
assert len(coresys.backups.list_backups) == 1
525+
assert (backup := coresys.backups.get("7fed74c8"))
526+
assert backup.location == location
527+
assert backup.locations == [location]
528+
529+
530+
async def test_invalid_reload(api_client: TestClient):
531+
"""Test invalid reload."""
532+
resp = await api_client.post("/backups/reload", json={"location": "no_filename"})
533+
assert resp.status == 400
534+
535+
resp = await api_client.post(
536+
"/backups/reload", json={"filename": "no_location.tar"}
537+
)
538+
assert resp.status == 400
539+
540+
resp = await api_client.post(
541+
"/backups/reload", json={"location": None, "filename": "no/sub/paths.tar"}
542+
)
543+
assert resp.status == 400
544+
545+
resp = await api_client.post(
546+
"/backups/reload", json={"location": None, "filename": "not_tar.tar.gz"}
547+
)
548+
assert resp.status == 400
549+
550+
551+
@pytest.mark.usefixtures("install_addon_ssh")
552+
@pytest.mark.parametrize("api_client", TEST_ADDON_SLUG, indirect=True)
553+
async def test_cloud_backup_core_only(api_client: TestClient, mock_full_backup: Backup):
554+
"""Test only core can access cloud backup location."""
555+
resp = await api_client.post(
556+
"/backups/reload",
557+
json={"location": ".cloud_backup", "filename": "caller_not_core.tar"},
558+
)
559+
assert resp.status == 403
560+
561+
resp = await api_client.post(
562+
"/backups/new/full",
563+
json={
564+
"name": "Mount test",
565+
"location": ".cloud_backup",
566+
},
567+
)
568+
assert resp.status == 403
569+
570+
resp = await api_client.post(
571+
"/backups/new/partial",
572+
json={"name": "Test", "homeassistant": True, "location": ".cloud_backup"},
573+
)
574+
assert resp.status == 403
575+
576+
# pylint: disable-next=protected-access
577+
mock_full_backup._locations = {".cloud_backup": None}
578+
assert mock_full_backup.location == ".cloud_backup"
579+
580+
resp = await api_client.post(f"/backups/{mock_full_backup.slug}/restore/full")
581+
assert resp.status == 403
582+
583+
resp = await api_client.post(
584+
f"/backups/{mock_full_backup.slug}/restore/partial",
585+
json={"homeassistant": True},
586+
)
587+
assert resp.status == 403
588+
589+
resp = await api_client.delete(f"/backups/{mock_full_backup.slug}")
590+
assert resp.status == 403

tests/api/test_mounts.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ async def test_api_create_mount(
8181
"share": "backups",
8282
"state": "active",
8383
"read_only": False,
84+
"user_path": "/backup/backup_test",
8485
}
8586
]
8687
coresys.mounts.save_data.assert_called_once()
@@ -257,6 +258,7 @@ async def test_api_update_mount(
257258
"share": "new_backups",
258259
"state": "active",
259260
"read_only": False,
261+
"user_path": "/backup/backup_test",
260262
}
261263
]
262264
coresys.mounts.save_data.assert_called_once()
@@ -292,8 +294,9 @@ async def test_api_update_dbus_error_mount_remains(
292294
"""Test mount remains in list with unsuccessful state if dbus error occurs during update."""
293295
systemd_service: SystemdService = all_dbus_services["systemd"]
294296
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
295-
systemd_unit_service.active_state = ["failed", "inactive"]
297+
systemd_unit_service.active_state = ["failed", "inactive", "failed", "inactive"]
296298
systemd_service.response_get_unit = [
299+
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
297300
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
298301
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
299302
]
@@ -325,6 +328,7 @@ async def test_api_update_dbus_error_mount_remains(
325328
"share": "backups",
326329
"state": None,
327330
"read_only": False,
331+
"user_path": "/backup/backup_test",
328332
}
329333
]
330334

@@ -372,6 +376,7 @@ async def test_api_update_dbus_error_mount_remains(
372376
"share": "backups",
373377
"state": None,
374378
"read_only": False,
379+
"user_path": "/backup/backup_test",
375380
}
376381
]
377382

@@ -828,6 +833,7 @@ async def test_api_create_read_only_cifs_mount(
828833
"share": "media",
829834
"state": "active",
830835
"read_only": True,
836+
"user_path": "/media/media_test",
831837
}
832838
]
833839
coresys.mounts.save_data.assert_called_once()
@@ -868,6 +874,7 @@ async def test_api_create_read_only_nfs_mount(
868874
"path": "/media/camera",
869875
"state": "active",
870876
"read_only": True,
877+
"user_path": "/media/media_test",
871878
}
872879
]
873880
coresys.mounts.save_data.assert_called_once()

0 commit comments

Comments
 (0)