From ccd2c31390d4295529198aa2f5a9eb5939f59772 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 7 Jun 2022 04:02:21 -0400 Subject: [PATCH] Move repository urls to store settings file (#3665) * Move repository urls to store settings file * Remove default repos from supervisor config * Fix clone at initial store load * Mock git load in repository fixture --- supervisor/api/store.py | 2 +- supervisor/api/supervisor.py | 5 +- supervisor/backups/backup.py | 10 +- supervisor/backups/validate.py | 3 +- supervisor/config.py | 4 + supervisor/misc/filter.py | 4 +- .../resolution/fixups/store_execute_remove.py | 7 +- supervisor/store/__init__.py | 62 +++++++++--- supervisor/store/const.py | 5 + supervisor/store/git.py | 2 +- supervisor/store/repository.py | 1 - supervisor/store/validate.py | 48 ++++++++- supervisor/validate.py | 10 +- tests/api/test_store.py | 4 +- tests/api/test_supervisor.py | 18 ++-- tests/conftest.py | 39 +++++--- .../addons/git/5c53de3b/repository.yaml | 3 + .../addons/git/a0d7b954/repository.yaml | 3 + .../fixup/test_store_execute_remove.py | 22 ++--- tests/store/test_custom_repository.py | 58 +++++++---- tests/store/test_store_manager.py | 98 +++++++++++++++++++ tests/store/test_validate.py | 58 +++++++++++ 22 files changed, 369 insertions(+), 97 deletions(-) create mode 100644 tests/fixtures/addons/git/5c53de3b/repository.yaml create mode 100644 tests/fixtures/addons/git/a0d7b954/repository.yaml create mode 100644 tests/store/test_store_manager.py create mode 100644 tests/store/test_validate.py diff --git a/supervisor/api/store.py b/supervisor/api/store.py index c7abcbec7..e0d536c13 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -35,7 +35,7 @@ from ..coresys import CoreSysAttributes from ..exceptions import APIError, APIForbidden from ..store.addon import AddonStore from ..store.repository import Repository -from ..validate import validate_repository +from ..store.validate import validate_repository SCHEMA_UPDATE = vol.Schema( { diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index acef7fc24..b87806976 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -46,8 +46,9 @@ from ..const import ( ) from ..coresys import CoreSysAttributes from ..exceptions import APIError +from ..store.validate import repositories from ..utils.validate import validate_timezone -from ..validate import repositories, version_tag, wait_boot +from ..validate import version_tag, wait_boot from .utils import api_process, api_process_raw, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -115,7 +116,7 @@ class APISupervisor(CoreSysAttributes): ATTR_DEBUG_BLOCK: self.sys_config.debug_block, ATTR_DIAGNOSTICS: self.sys_config.diagnostics, ATTR_ADDONS: list_addons, - ATTR_ADDONS_REPOSITORIES: self.sys_config.addons_repositories, + ATTR_ADDONS_REPOSITORIES: self.sys_store.repository_urls, } @api_process diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 985ca19b1..179e8f7de 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -497,18 +497,16 @@ class Backup(CoreSysAttributes): def store_repositories(self): """Store repository list into backup.""" - self.repositories = self.sys_config.addons_repositories + self.repositories = self.sys_store.repository_urls async def restore_repositories(self, replace: bool = False): """Restore repositories from backup. Return a coroutine. """ - new_list: set[str] = set(self.repositories) - if not replace: - new_list.update(self.sys_config.addons_repositories) - - await self.sys_store.update_repositories(list(new_list), add_with_errors=True) + await self.sys_store.update_repositories( + self.repositories, add_with_errors=True, replace=replace + ) def store_dockerconfig(self): """Store the configuration for Docker.""" diff --git a/supervisor/backups/validate.py b/supervisor/backups/validate.py index f70a9784c..4d83d7e2f 100644 --- a/supervisor/backups/validate.py +++ b/supervisor/backups/validate.py @@ -28,7 +28,8 @@ from ..const import ( FOLDER_SHARE, FOLDER_SSL, ) -from ..validate import SCHEMA_DOCKER_CONFIG, repositories, version_tag +from ..store.validate import repositories +from ..validate import SCHEMA_DOCKER_CONFIG, version_tag ALL_FOLDERS = [ FOLDER_SHARE, diff --git a/supervisor/config.py b/supervisor/config.py index ba693ec85..145dedbf4 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -327,3 +327,7 @@ class CoreConfig(FileConfiguration): return self._data[ATTR_ADDONS_CUSTOM_LIST].remove(repo) + + def clear_addons_repositories(self) -> None: + """Clear custom repositories list from core config.""" + self._data[ATTR_ADDONS_CUSTOM_LIST] = [] diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index f82191099..568686b9c 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -52,7 +52,6 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: "supervisor": { "channel": coresys.updater.channel, "installed_addons": installed_addons, - "repositories": coresys.config.addons_repositories, }, "host": { "arch": coresys.arch.default, @@ -79,6 +78,9 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: ], "unhealthy": coresys.resolution.unhealthy, }, + "store": { + "repositories": coresys.store.repository_urls, + }, "misc": { "fallback_dns": coresys.plugins.dns.fallback, }, diff --git a/supervisor/resolution/fixups/store_execute_remove.py b/supervisor/resolution/fixups/store_execute_remove.py index 937579d07..99336f1ce 100644 --- a/supervisor/resolution/fixups/store_execute_remove.py +++ b/supervisor/resolution/fixups/store_execute_remove.py @@ -29,14 +29,9 @@ class FixupStoreExecuteRemove(FixupBase): # Remove repository try: - await repository.remove() + await self.sys_store.remove_repository(repository) except StoreError: raise ResolutionFixupError() from None - else: - self.sys_store.repositories.pop(repository.slug, None) - - self.sys_config.drop_addon_repository(repository.source) - self.sys_config.save_data() @property def suggestion(self) -> SuggestionType: diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index aecfe7825..44dd77bd7 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -2,7 +2,10 @@ import asyncio import logging -from ..const import URL_HASSIO_ADDONS +from supervisor.store.validate import SCHEMA_STORE_FILE +from supervisor.utils.common import FileConfiguration + +from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ( StoreError, @@ -15,29 +18,43 @@ from ..exceptions import ( from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from .addon import AddonStore -from .const import StoreType +from .const import FILE_HASSIO_STORE, StoreType from .data import StoreData from .repository import Repository +from .validate import BUILTIN_REPOSITORIES, ensure_builtin_repositories _LOGGER: logging.Logger = logging.getLogger(__name__) -BUILTIN_REPOSITORIES = {StoreType.CORE.value, StoreType.LOCAL.value} - -class StoreManager(CoreSysAttributes): +class StoreManager(CoreSysAttributes, FileConfiguration): """Manage add-ons inside Supervisor.""" def __init__(self, coresys: CoreSys): """Initialize Docker base wrapper.""" + super().__init__(FILE_HASSIO_STORE, SCHEMA_STORE_FILE) self.coresys: CoreSys = coresys self.data = StoreData(coresys) - self.repositories: dict[str, Repository] = {} + self._repositories: dict[str, Repository] = {} @property def all(self) -> list[Repository]: """Return list of add-on repositories.""" return list(self.repositories.values()) + @property + def repositories(self) -> dict[str, Repository]: + """Return repositories dictionary.""" + return self._repositories + + @property + def repository_urls(self) -> list[str]: + """Return source URL for all git repositories.""" + return [ + repository.url + for repository in self.all + if repository.type == StoreType.GIT + ] + def get(self, slug: str) -> Repository: """Return Repository with slug.""" if slug not in self.repositories: @@ -56,11 +73,17 @@ class StoreManager(CoreSysAttributes): """Start up add-on management.""" await self.data.update() - # Init Supervisor built-in repositories - repositories = set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES + # Backwards compatibility - Remove after 2022.9 + if len(self.sys_config.addons_repositories) > 0: + self._data[ATTR_REPOSITORIES] = ensure_builtin_repositories( + self.sys_config.addons_repositories + ) + self.sys_config.clear_addons_repositories() # Init custom repositories and load add-ons - await self.update_repositories(repositories, add_with_errors=True) + await self.update_repositories( + self._data[ATTR_REPOSITORIES], add_with_errors=True + ) async def reload(self) -> None: """Update add-ons from repository and reload list.""" @@ -144,9 +167,9 @@ class StoreManager(CoreSysAttributes): ) # Add Repository to list - if repository.type == StoreType.GIT: - self.sys_config.add_addon_repository(repository.source) + self._data[ATTR_REPOSITORIES].append(url) self.repositories[repository.slug] = repository + self.save_data() # Persist changes if persist: @@ -155,7 +178,7 @@ class StoreManager(CoreSysAttributes): async def remove_repository(self, repository: Repository, *, persist: bool = True): """Remove a repository.""" - if repository.type != StoreType.GIT: + if repository.url in BUILTIN_REPOSITORIES: raise StoreInvalidAddonRepo( "Can't remove built-in repositories!", logger=_LOGGER.error ) @@ -166,7 +189,8 @@ class StoreManager(CoreSysAttributes): logger=_LOGGER.error, ) await self.repositories.pop(repository.slug).remove() - self.sys_config.drop_addon_repository(repository.url) + self._data[ATTR_REPOSITORIES].remove(repository.url) + self.save_data() if persist: await self.data.update() @@ -174,10 +198,18 @@ class StoreManager(CoreSysAttributes): @Job(conditions=[JobCondition.INTERNET_SYSTEM]) async def update_repositories( - self, list_repositories, *, add_with_errors: bool = False + self, + list_repositories: list[str], + *, + add_with_errors: bool = False, + replace: bool = True, ): """Add a new custom repository.""" - new_rep = set(list_repositories) + new_rep = set( + ensure_builtin_repositories(list_repositories) + if replace + else list_repositories + self.repository_urls + ) old_rep = {repository.source for repository in self.all} # Add new repositories diff --git a/supervisor/store/const.py b/supervisor/store/const.py index 43cbc989d..fb9776046 100644 --- a/supervisor/store/const.py +++ b/supervisor/store/const.py @@ -1,5 +1,10 @@ """Constants for the add-on store.""" from enum import Enum +from pathlib import Path + +from supervisor.const import SUPERVISOR_DATA + +FILE_HASSIO_STORE = Path(SUPERVISOR_DATA, "store.json") class StoreType(str, Enum): diff --git a/supervisor/store/git.py b/supervisor/store/git.py index 4241d7d8e..d549ed327 100644 --- a/supervisor/store/git.py +++ b/supervisor/store/git.py @@ -13,8 +13,8 @@ from ..exceptions import StoreGitCloneError, StoreGitError, StoreJobError from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils import remove_folder -from ..validate import RE_REPOSITORY from .utils import get_hash_from_repository +from .validate import RE_REPOSITORY _LOGGER: logging.Logger = logging.getLogger(__name__) diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index f960e4282..48ee352e8 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -36,7 +36,6 @@ class Repository(CoreSysAttributes): self._type = StoreType.CORE else: self.git = GitRepoCustom(coresys, repository) - self.source = repository self._slug = get_hash_from_repository(repository) self._type = StoreType.GIT diff --git a/supervisor/store/validate.py b/supervisor/store/validate.py index 9ce8dd48c..9d3e8b90b 100644 --- a/supervisor/store/validate.py +++ b/supervisor/store/validate.py @@ -2,7 +2,19 @@ import voluptuous as vol -from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_URL +from supervisor.store.const import StoreType + +from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_REPOSITORIES, ATTR_URL +from ..validate import RE_REPOSITORY + +URL_COMMUNITY_ADDONS = "https://github.com/hassio-addons/repository" +URL_ESPHOME = "https://github.com/esphome/home-assistant-addon" +BUILTIN_REPOSITORIES = { + StoreType.CORE.value, + StoreType.LOCAL.value, + URL_COMMUNITY_ADDONS, + URL_ESPHOME, +} # pylint: disable=no-value-for-parameter SCHEMA_REPOSITORY_CONFIG = vol.Schema( @@ -13,3 +25,37 @@ SCHEMA_REPOSITORY_CONFIG = vol.Schema( }, extra=vol.REMOVE_EXTRA, ) + + +def validate_repository(repository: str) -> str: + """Validate a valid repository.""" + if repository in [StoreType.CORE.value, StoreType.LOCAL.value]: + return repository + + data = RE_REPOSITORY.match(repository) + if not data: + raise vol.Invalid("No valid repository format!") from None + + # Validate URL + # pylint: disable=no-value-for-parameter + vol.Url()(data.group("url")) + + return repository + + +def ensure_builtin_repositories(addon_repositories: list[str]) -> list[str]: + """Ensure builtin repositories are in list.""" + return list(set(addon_repositories) | BUILTIN_REPOSITORIES) + + +# pylint: disable=no-value-for-parameter +repositories = vol.All([validate_repository], vol.Unique(), ensure_builtin_repositories) + +SCHEMA_STORE_FILE = vol.Schema( + { + vol.Optional( + ATTR_REPOSITORIES, default=list(BUILTIN_REPOSITORIES) + ): repositories, + }, + extra=vol.REMOVE_EXTRA, +) diff --git a/supervisor/validate.py b/supervisor/validate.py index 94e9a5e1e..4323c6511 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -41,6 +41,7 @@ from .const import ( ) from .utils.validate import validate_timezone +# Move to store.validate when addons_repository config removed RE_REPOSITORY = re.compile(r"^(?P[^#]+)(?:#(?P[\w\-]+))?$") RE_REGISTRY = re.compile(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$") @@ -84,6 +85,7 @@ def dns_url(url: str) -> str: dns_server_list = vol.All(vol.Length(max=8), [dns_url]) +# Remove with addons_repositories config def validate_repository(repository: str) -> str: """Validate a valid repository.""" data = RE_REPOSITORY.match(repository) @@ -146,13 +148,7 @@ SCHEMA_SUPERVISOR_CONFIG = vol.Schema( ATTR_VERSION, default=AwesomeVersion(SUPERVISOR_VERSION) ): version_tag, vol.Optional(ATTR_IMAGE): docker_image, - vol.Optional( - ATTR_ADDONS_CUSTOM_LIST, - default=[ - "https://github.com/hassio-addons/repository", - "https://github.com/esphome/home-assistant-addon", - ], - ): repositories, + vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): repositories, vol.Optional(ATTR_WAIT_BOOT, default=5): wait_boot, vol.Optional(ATTR_LOGGING, default=LogLevel.INFO): vol.Coerce(LogLevel), vol.Optional(ATTR_DEBUG, default=False): vol.Boolean(), diff --git a/tests/api/test_store.py b/tests/api/test_store.py index c38845c17..1eb35f6ee 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -82,7 +82,7 @@ async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys ) assert response.status == 200 - assert REPO_URL in coresys.config.addons_repositories + assert REPO_URL in coresys.store.repository_urls assert isinstance(coresys.store.get_from_url(REPO_URL), Repository) @@ -93,5 +93,5 @@ async def test_api_store_remove_repository( response = await api_client.delete(f"/store/repositories/{repository.slug}") assert response.status == 200 - assert repository.url not in coresys.config.addons_repositories + assert repository.url not in coresys.store.repository_urls assert repository.slug not in coresys.store.repositories diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 0807267e2..9789cad50 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -26,7 +26,7 @@ async def test_api_supervisor_options_add_repository( api_client: TestClient, coresys: CoreSys ): """Test add a repository via POST /supervisor/options REST API.""" - assert REPO_URL not in coresys.config.addons_repositories + assert REPO_URL not in coresys.store.repository_urls with pytest.raises(StoreNotFound): coresys.store.get_from_url(REPO_URL) @@ -38,7 +38,7 @@ async def test_api_supervisor_options_add_repository( ) assert response.status == 200 - assert REPO_URL in coresys.config.addons_repositories + assert REPO_URL in coresys.store.repository_urls assert isinstance(coresys.store.get_from_url(REPO_URL), Repository) @@ -46,7 +46,7 @@ async def test_api_supervisor_options_remove_repository( api_client: TestClient, coresys: CoreSys, repository: Repository ): """Test remove a repository via POST /supervisor/options REST API.""" - assert repository.url in coresys.config.addons_repositories + assert repository.url in coresys.store.repository_urls assert repository.slug in coresys.store.repositories response = await api_client.post( @@ -54,7 +54,7 @@ async def test_api_supervisor_options_remove_repository( ) assert response.status == 200 - assert repository.url not in coresys.config.addons_repositories + assert repository.url not in coresys.store.repository_urls assert repository.slug not in coresys.store.repositories @@ -65,14 +65,18 @@ async def test_api_supervisor_options_repositories_skipped_on_error( """Test repositories skipped on error via POST /supervisor/options REST API.""" with patch( "supervisor.store.repository.Repository.load", side_effect=git_error - ), patch("supervisor.store.repository.Repository.validate", return_value=False): + ), patch( + "supervisor.store.repository.Repository.validate", return_value=False + ), patch( + "supervisor.store.repository.Repository.remove" + ): response = await api_client.post( "/supervisor/options", json={"addons_repositories": [REPO_URL]} ) assert response.status == 400 assert len(coresys.resolution.suggestions) == 0 - assert REPO_URL not in coresys.config.addons_repositories + assert REPO_URL not in coresys.store.repository_urls with pytest.raises(StoreNotFound): coresys.store.get_from_url(REPO_URL) @@ -92,7 +96,7 @@ async def test_api_supervisor_options_repo_error_with_config_change( ) assert response.status == 400 - assert REPO_URL not in coresys.config.addons_repositories + assert REPO_URL not in coresys.store.repository_urls assert coresys.config.debug coresys.updater.save_data.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py index 80466c919..caeba096f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ from supervisor import config as su_config from supervisor.addons.addon import Addon from supervisor.api import RestAPI from supervisor.bootstrap import initialize_coresys -from supervisor.const import REQUEST_FROM +from supervisor.const import ATTR_REPOSITORIES, REQUEST_FROM from supervisor.coresys import CoreSys from supervisor.dbus.agent import OSAgent from supervisor.dbus.const import DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED @@ -39,7 +39,6 @@ from .const import TEST_ADDON_SLUG async def mock_async_return_true() -> bool: """Mock methods to return True.""" - return True @@ -74,7 +73,6 @@ def docker() -> DockerAPI: @pytest.fixture def dbus() -> DBus: """Mock DBUS.""" - dbus_commands = [] async def mock_get_properties(dbus_obj, interface): @@ -208,6 +206,7 @@ async def coresys(loop, docker, network_manager, aiohttp_client, run_dir) -> Cor coresys_obj._jobs.save_data = MagicMock() coresys_obj._resolution.save_data = MagicMock() coresys_obj._addons.data.save_data = MagicMock() + coresys_obj._store.save_data = MagicMock() # Mock test client coresys_obj.arch._default_arch = "amd64" @@ -246,7 +245,9 @@ async def coresys(loop, docker, network_manager, aiohttp_client, run_dir) -> Cor unwrap(coresys_obj.updater.fetch_data), coresys_obj.updater ) - yield coresys_obj + # Don't remove files/folders related to addons and stores + with patch("supervisor.store.git.GitRepo._remove"): + yield coresys_obj await coresys_obj.websession.close() @@ -315,21 +316,29 @@ def store_addon(coresys: CoreSys, tmp_path, repository): @pytest.fixture async def repository(coresys: CoreSys): """Repository fixture.""" - coresys.config.drop_addon_repository("https://github.com/hassio-addons/repository") - coresys.config.drop_addon_repository( + coresys.store._data[ATTR_REPOSITORIES].remove( + "https://github.com/hassio-addons/repository" + ) + coresys.store._data[ATTR_REPOSITORIES].remove( "https://github.com/esphome/home-assistant-addon" ) - await coresys.store.load() - repository_obj = Repository( - coresys, "https://github.com/awesome-developer/awesome-repo" - ) + coresys.config.clear_addons_repositories() - coresys.store.repositories[repository_obj.slug] = repository_obj - coresys.config.add_addon_repository( - "https://github.com/awesome-developer/awesome-repo" - ) + with patch( + "supervisor.store.validate.BUILTIN_REPOSITORIES", {"local", "core"} + ), patch("supervisor.store.git.GitRepo.load", return_value=None): + await coresys.store.load() - yield repository_obj + repository_obj = Repository( + coresys, "https://github.com/awesome-developer/awesome-repo" + ) + + coresys.store.repositories[repository_obj.slug] = repository_obj + coresys.store._data[ATTR_REPOSITORIES].append( + "https://github.com/awesome-developer/awesome-repo" + ) + + yield repository_obj @pytest.fixture diff --git a/tests/fixtures/addons/git/5c53de3b/repository.yaml b/tests/fixtures/addons/git/5c53de3b/repository.yaml new file mode 100644 index 000000000..766eb5cce --- /dev/null +++ b/tests/fixtures/addons/git/5c53de3b/repository.yaml @@ -0,0 +1,3 @@ +name: ESPHome add-ons +url: "https://home-assistant.io" +maintainer: dev@ha.com diff --git a/tests/fixtures/addons/git/a0d7b954/repository.yaml b/tests/fixtures/addons/git/a0d7b954/repository.yaml new file mode 100644 index 000000000..71478ea6a --- /dev/null +++ b/tests/fixtures/addons/git/a0d7b954/repository.yaml @@ -0,0 +1,3 @@ +name: Community add-ons +url: "https://home-assistant.io" +maintainer: dev@ha.com diff --git a/tests/resolution/fixup/test_store_execute_remove.py b/tests/resolution/fixup/test_store_execute_remove.py index 86714245f..bc43e05c9 100644 --- a/tests/resolution/fixup/test_store_execute_remove.py +++ b/tests/resolution/fixup/test_store_execute_remove.py @@ -1,36 +1,34 @@ """Test evaluation base.""" # pylint: disable=import-error,protected-access -from unittest.mock import AsyncMock +from unittest.mock import patch from supervisor.coresys import CoreSys from supervisor.resolution.const import ContextType, IssueType, SuggestionType from supervisor.resolution.data import Issue, Suggestion from supervisor.resolution.fixups.store_execute_remove import FixupStoreExecuteRemove +from supervisor.store.repository import Repository -async def test_fixup(coresys: CoreSys): +async def test_fixup(coresys: CoreSys, repository: Repository): """Test fixup.""" store_execute_remove = FixupStoreExecuteRemove(coresys) assert store_execute_remove.auto is False coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="test" + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference=repository.slug ) coresys.resolution.issues = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test" + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repository.slug ) - mock_repositorie = AsyncMock() - mock_repositorie.slug = "test" + with patch.object(type(repository), "remove") as remove_repo: + await store_execute_remove() - coresys.store.repositories["test"] = mock_repositorie + assert remove_repo.called - await store_execute_remove() - - assert mock_repositorie.remove.called - assert coresys.config.save_data.called + assert coresys.store.save_data.called assert len(coresys.resolution.suggestions) == 0 assert len(coresys.resolution.issues) == 0 - assert "test" not in coresys.store.repositories + assert repository.slug not in coresys.store.repositories diff --git a/tests/store/test_custom_repository.py b/tests/store/test_custom_repository.py index d6fd07cef..93bcf9683 100644 --- a/tests/store/test_custom_repository.py +++ b/tests/store/test_custom_repository.py @@ -23,7 +23,7 @@ async def test_add_valid_repository( coresys: CoreSys, store_manager: StoreManager, use_update: bool ): """Test add custom repository.""" - current = coresys.config.addons_repositories + current = coresys.store.repository_urls with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "supervisor.utils.common.read_yaml_file", return_value={"name": "Awesome repository"}, @@ -35,7 +35,7 @@ async def test_add_valid_repository( assert store_manager.get_from_url("http://example.com").validate() - assert "http://example.com" in coresys.config.addons_repositories + assert "http://example.com" in coresys.store.repository_urls @pytest.mark.parametrize("use_update", [True, False]) @@ -43,7 +43,7 @@ async def test_add_invalid_repository( coresys: CoreSys, store_manager: StoreManager, use_update: bool ): """Test add invalid custom repository.""" - current = coresys.config.addons_repositories + current = coresys.store.repository_urls with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value="", @@ -59,7 +59,7 @@ async def test_add_invalid_repository( assert not store_manager.get_from_url("http://example.com").validate() - assert "http://example.com" in coresys.config.addons_repositories + assert "http://example.com" in coresys.store.repository_urls assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE @@ -68,7 +68,7 @@ async def test_error_on_invalid_repository( coresys: CoreSys, store_manager: StoreManager, use_update ): """Test invalid repository not added.""" - current = coresys.config.addons_repositories + current = coresys.store.repository_urls with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value="", @@ -78,7 +78,7 @@ async def test_error_on_invalid_repository( else: await store_manager.add_repository("http://example.com") - assert "http://example.com" not in coresys.config.addons_repositories + assert "http://example.com" not in coresys.store.repository_urls assert len(coresys.resolution.suggestions) == 0 with pytest.raises(StoreNotFound): store_manager.get_from_url("http://example.com") @@ -89,7 +89,7 @@ async def test_add_invalid_repository_file( coresys: CoreSys, store_manager: StoreManager, use_update: bool ): """Test add invalid custom repository file.""" - current = coresys.config.addons_repositories + current = coresys.store.repository_urls with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value=json.dumps({"name": "Awesome repository"}), @@ -105,7 +105,7 @@ async def test_add_invalid_repository_file( assert not store_manager.get_from_url("http://example.com").validate() - assert "http://example.com" in coresys.config.addons_repositories + assert "http://example.com" in coresys.store.repository_urls assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE @@ -126,7 +126,7 @@ async def test_add_repository_with_git_error( suggestion_type: SuggestionType, ): """Test repo added with issue on git error.""" - current = coresys.config.addons_repositories + current = coresys.store.repository_urls with patch("supervisor.store.repository.Repository.load", side_effect=git_error): if use_update: await store_manager.update_repositories( @@ -137,7 +137,7 @@ async def test_add_repository_with_git_error( "http://example.com", add_with_errors=True ) - assert "http://example.com" in coresys.config.addons_repositories + assert "http://example.com" in coresys.store.repository_urls assert coresys.resolution.suggestions[-1].type == suggestion_type assert isinstance(store_manager.get_from_url("http://example.com"), Repository) @@ -158,7 +158,7 @@ async def test_error_on_repository_with_git_error( git_error: StoreGitError, ): """Test repo not added on git error.""" - current = coresys.config.addons_repositories + current = coresys.store.repository_urls with patch( "supervisor.store.repository.Repository.load", side_effect=git_error ), pytest.raises(StoreError): @@ -167,7 +167,7 @@ async def test_error_on_repository_with_git_error( else: await store_manager.add_repository("http://example.com") - assert "http://example.com" not in coresys.config.addons_repositories + assert "http://example.com" not in coresys.store.repository_urls assert len(coresys.resolution.suggestions) == 0 with pytest.raises(StoreNotFound): store_manager.get_from_url("http://example.com") @@ -182,6 +182,8 @@ async def test_preinstall_valid_repository( await store_manager.update_repositories(BUILTIN_REPOSITORIES) assert store_manager.get("core").validate() assert store_manager.get("local").validate() + assert store_manager.get("a0d7b954").validate() + assert store_manager.get("5c53de3b").validate() @pytest.mark.parametrize("use_update", [True, False]) @@ -192,7 +194,7 @@ async def test_remove_repository( use_update: bool, ): """Test removing a custom repository.""" - assert repository.url in coresys.config.addons_repositories + assert repository.url in coresys.store.repository_urls assert repository.slug in coresys.store.repositories if use_update: @@ -200,7 +202,7 @@ async def test_remove_repository( else: await store_manager.remove_repository(repository) - assert repository.url not in coresys.config.addons_repositories + assert repository.url not in coresys.store.repository_urls assert repository.slug not in coresys.addons.store assert repository.slug not in coresys.store.repositories @@ -233,15 +235,16 @@ async def test_remove_used_repository( async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManager): """Test partial error on update does partial save and errors.""" - current = coresys.config.addons_repositories - initial = len(current) with patch("supervisor.store.repository.Repository.validate", return_value=True): with patch("supervisor.store.repository.Repository.load", return_value=None): - await store_manager.update_repositories(current) + await store_manager.update_repositories([]) store_manager.data.update.assert_called_once() store_manager.data.update.reset_mock() + current = coresys.store.repository_urls + initial = len(current) + with patch( "supervisor.store.repository.Repository.load", side_effect=[None, StoreGitError()], @@ -250,7 +253,7 @@ async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManage current + ["http://example.com", "http://example2.com"] ) - assert len(coresys.config.addons_repositories) == initial + 1 + assert len(coresys.store.repository_urls) == initial + 1 store_manager.data.update.assert_called_once() @@ -258,7 +261,7 @@ async def test_error_adding_duplicate( coresys: CoreSys, store_manager: StoreManager, repository: Repository ): """Test adding a duplicate repository causes an error.""" - assert repository.url in coresys.config.addons_repositories + assert repository.url in coresys.store.repository_urls with patch( "supervisor.store.repository.Repository.validate", return_value=True ), patch( @@ -267,3 +270,20 @@ async def test_error_adding_duplicate( StoreError ): await store_manager.add_repository(repository.url) + + +async def test_add_with_update_repositories( + coresys: CoreSys, store_manager: StoreManager, repository: Repository +): + """Test adding repositories to existing ones using update.""" + assert repository.url in coresys.store.repository_urls + assert "http://example.com" not in coresys.store.repository_urls + + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( + "supervisor.utils.common.read_yaml_file", + return_value={"name": "Awesome repository"}, + ), patch("pathlib.Path.exists", return_value=True): + await store_manager.update_repositories(["http://example.com"], replace=False) + + assert repository.url in coresys.store.repository_urls + assert "http://example.com" in coresys.store.repository_urls diff --git a/tests/store/test_store_manager.py b/tests/store/test_store_manager.py new file mode 100644 index 000000000..5e4883ee2 --- /dev/null +++ b/tests/store/test_store_manager.py @@ -0,0 +1,98 @@ +"""Test store manager.""" +from unittest.mock import patch + +from supervisor.const import ATTR_ADDONS_CUSTOM_LIST +from supervisor.coresys import CoreSys +from supervisor.store import StoreManager +from supervisor.store.repository import Repository + + +async def test_default_load(coresys: CoreSys): + """Test default load from config.""" + store_manager = StoreManager(coresys) + + with patch( + "supervisor.store.repository.Repository.load", return_value=None + ), patch.object( + type(coresys.config), "addons_repositories", return_value=[] + ), patch( + "pathlib.Path.exists", return_value=True + ): + await store_manager.load() + + assert len(store_manager.all) == 4 + assert isinstance(store_manager.get("core"), Repository) + assert isinstance(store_manager.get("local"), Repository) + + assert len(store_manager.repository_urls) == 2 + assert ( + "https://github.com/hassio-addons/repository" in store_manager.repository_urls + ) + assert ( + "https://github.com/esphome/home-assistant-addon" + in store_manager.repository_urls + ) + + +async def test_load_with_custom_repository(coresys: CoreSys): + """Test load from config with custom repository.""" + with patch( + "supervisor.utils.common.read_json_or_yaml_file", + return_value={"repositories": ["http://example.com"]}, + ), patch("pathlib.Path.is_file", return_value=True): + store_manager = StoreManager(coresys) + + with patch( + "supervisor.store.repository.Repository.load", return_value=None + ), patch.object( + type(coresys.config), "addons_repositories", return_value=[] + ), patch( + "supervisor.store.repository.Repository.validate", return_value=True + ), patch( + "pathlib.Path.exists", return_value=True + ): + await store_manager.load() + + assert len(store_manager.all) == 5 + assert isinstance(store_manager.get("core"), Repository) + assert isinstance(store_manager.get("local"), Repository) + + assert len(store_manager.repository_urls) == 3 + assert ( + "https://github.com/hassio-addons/repository" in store_manager.repository_urls + ) + assert ( + "https://github.com/esphome/home-assistant-addon" + in store_manager.repository_urls + ) + assert "http://example.com" in store_manager.repository_urls + + +async def test_load_from_core_config(coresys: CoreSys): + """Test custom repositories loaded from core config when present.""" + store_manager = StoreManager(coresys) + + # pylint: disable=protected-access + coresys.config._data[ATTR_ADDONS_CUSTOM_LIST] = ["http://example.com"] + assert coresys.config.addons_repositories == ["http://example.com"] + + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( + "supervisor.store.repository.Repository.validate", return_value=True + ), patch("pathlib.Path.exists", return_value=True): + await store_manager.load() + + assert len(store_manager.all) == 5 + assert isinstance(store_manager.get("core"), Repository) + assert isinstance(store_manager.get("local"), Repository) + + assert len(store_manager.repository_urls) == 3 + assert ( + "https://github.com/hassio-addons/repository" in store_manager.repository_urls + ) + assert ( + "https://github.com/esphome/home-assistant-addon" + in store_manager.repository_urls + ) + assert "http://example.com" in store_manager.repository_urls + + assert coresys.config.addons_repositories == [] diff --git a/tests/store/test_validate.py b/tests/store/test_validate.py new file mode 100644 index 000000000..fcf895b4a --- /dev/null +++ b/tests/store/test_validate.py @@ -0,0 +1,58 @@ +"""Test schema validation.""" +from typing import Any + +import pytest +from voluptuous import Invalid + +from supervisor.const import ATTR_REPOSITORIES +from supervisor.store.validate import SCHEMA_STORE_FILE, repositories + + +@pytest.mark.parametrize( + "config", + [ + {}, + {ATTR_REPOSITORIES: []}, + {ATTR_REPOSITORIES: ["https://github.com/esphome/home-assistant-addon"]}, + ], +) +async def test_default_config(config: dict[Any]): + """Test built-ins included by default.""" + conf = SCHEMA_STORE_FILE(config) + assert ATTR_REPOSITORIES in conf + assert "core" in conf[ATTR_REPOSITORIES] + assert "local" in conf[ATTR_REPOSITORIES] + assert "https://github.com/hassio-addons/repository" in conf[ATTR_REPOSITORIES] + assert 1 == len( + [ + repo + for repo in conf[ATTR_REPOSITORIES] + if repo == "https://github.com/esphome/home-assistant-addon" + ] + ) + + +@pytest.mark.parametrize( + "repo_list,valid", + [ + ([], True), + (["core", "local"], True), + (["https://github.com/hassio-addons/repository"], True), + (["not_a_url"], False), + (["https://fail.com/duplicate", "https://fail.com/duplicate"], False), + ], +) +async def test_repository_validate(repo_list: list[str], valid: bool): + """Test repository list validate.""" + if valid: + processed = repositories(repo_list) + assert len(processed) == 4 + assert set(repositories(repo_list)) == { + "core", + "local", + "https://github.com/hassio-addons/repository", + "https://github.com/esphome/home-assistant-addon", + } + else: + with pytest.raises(Invalid): + repositories(repo_list)