diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py index c141015e4a2ba1..8b6030eb86f46b 100644 --- a/homeassistant/components/hassio/services.py +++ b/homeassistant/components/hassio/services.py @@ -7,6 +7,7 @@ from aiohasupervisor import SupervisorClient, SupervisorError from aiohasupervisor.models import ( + Folder, FullBackupOptions, FullRestoreOptions, PartialBackupOptions, @@ -70,6 +71,31 @@ VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) +# Legacy alias used by the Supervisor API for the homeassistant flag, kept +# for backwards compatibility with existing automations. +LEGACY_FOLDER_HOMEASSISTANT = "homeassistant" + + +def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]: + """Map legacy aliases used by both partial backup and partial restore handlers.""" + if ATTR_APPS in data: + data[ATTR_ADDONS] = data.pop(ATTR_APPS) + if ATTR_FOLDERS in data: + folders: set[Any] = set(data[ATTR_FOLDERS]) + if LEGACY_FOLDER_HOMEASSISTANT in folders: + folders.discard(LEGACY_FOLDER_HOMEASSISTANT) + if data.get(ATTR_HOMEASSISTANT) is False: + raise ServiceValidationError( + f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy " + f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}" + ) + data[ATTR_HOMEASSISTANT] = True + if folders: + data[ATTR_FOLDERS] = folders + else: + data.pop(ATTR_FOLDERS) + return data + def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" @@ -113,7 +139,10 @@ def valid_addon(value: Any) -> str: { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All( - cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + cv.ensure_list, + [vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))], + vol.Unique(), + vol.Coerce(set), ), vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) @@ -136,7 +165,10 @@ def valid_addon(value: Any) -> str: { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All( - cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + cv.ensure_list, + [vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))], + vol.Unique(), + vol.Coerce(set), ), vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) @@ -343,9 +375,7 @@ async def async_partial_backup_service_handler( service: ServiceCall, ) -> ServiceResponse: """Handler for create partial backup service. Returns the new backup's ID.""" - data = service.data.copy() - if ATTR_APPS in data: - data[ATTR_ADDONS] = data.pop(ATTR_APPS) + data = _normalize_partial_options_data(service.data.copy()) options = PartialBackupOptions(**data) try: @@ -392,8 +422,7 @@ async def async_partial_restore_service_handler(service: ServiceCall) -> None: """Handler for partial restore service.""" data = service.data.copy() backup_slug = data.pop(ATTR_SLUG) - if ATTR_APPS in data: - data[ATTR_ADDONS] = data.pop(ATTR_APPS) + data = _normalize_partial_options_data(data) options = PartialRestoreOptions(**data) try: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 9f633112a29993..f9762c7082b021 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -14,6 +14,7 @@ AddonStage, AddonState, CIFSMountResponse, + Folder, FullBackupOptions, HomeAssistantOptions, InstalledAddon, @@ -528,7 +529,7 @@ async def test_service_calls( name="2021-11-13 03:48:00", homeassistant=True, addons={"test"}, - folders={"ssl"}, + folders={Folder.SSL}, password="123456", ) ) @@ -552,7 +553,10 @@ async def test_service_calls( supervisor_client.backups.partial_restore.assert_called_once_with( "test", PartialRestoreOptions( - homeassistant=False, addons={"test"}, folders={"ssl"}, password="123456" + homeassistant=False, + addons={"test"}, + folders={Folder.SSL}, + password="123456", ), ) @@ -770,6 +774,80 @@ async def test_invalid_service_calls_folder_duplicates(hass: HomeAssistant) -> N ) +@pytest.mark.usefixtures("hassio_env") +async def test_partial_backup_legacy_homeassistant_folder( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test that the legacy "homeassistant" folder is translated to homeassistant=True.""" + assert await async_setup_component(hass, "hassio", {}) + supervisor_client.backups.partial_backup.return_value = NewBackup( + job_id=uuid4(), slug="partial" + ) + + await hass.services.async_call( + "hassio", + "backup_partial", + {"folders": ["homeassistant", "ssl"], "name": "test"}, + blocking=True, + ) + supervisor_client.backups.partial_backup.assert_called_once_with( + PartialBackupOptions( + name="test", + homeassistant=True, + folders={Folder.SSL}, + ) + ) + + +@pytest.mark.usefixtures("hassio_env") +async def test_partial_restore_legacy_homeassistant_folder( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test that the legacy "homeassistant" folder is translated for restore too.""" + assert await async_setup_component(hass, "hassio", {}) + + await hass.services.async_call( + "hassio", + "restore_partial", + {"slug": "test", "folders": ["homeassistant", "ssl"]}, + blocking=True, + ) + supervisor_client.backups.partial_restore.assert_called_once_with( + "test", + PartialRestoreOptions( + homeassistant=True, + folders={Folder.SSL}, + ), + ) + + +@pytest.mark.usefixtures("hassio_env", "supervisor_client") +async def test_partial_backup_invalid_folder(hass: HomeAssistant) -> None: + """Test that an unknown folder name is rejected.""" + assert await async_setup_component(hass, "hassio", {}) + + with pytest.raises(Invalid, match="not a valid value"): + await hass.services.async_call( + "hassio", "backup_partial", {"folders": ["bogus"]} + ) + + +@pytest.mark.usefixtures("hassio_env", "supervisor_client") +async def test_partial_backup_legacy_homeassistant_folder_conflict( + hass: HomeAssistant, +) -> None: + """Reject combining homeassistant=False with the legacy "homeassistant" folder.""" + assert await async_setup_component(hass, "hassio", {}) + + with pytest.raises(ServiceValidationError, match="conflicts"): + await hass.services.async_call( + "hassio", + "backup_partial", + {"homeassistant": False, "folders": ["homeassistant"]}, + blocking=True, + ) + + @pytest.mark.usefixtures("addon_installed") async def test_entry_load_and_unload(hass: HomeAssistant) -> None: """Test loading and unloading config entry."""