From 138fd7eec9c9bc297994b3b910454b8ab3076ce3 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 23 May 2022 03:16:42 -0400 Subject: [PATCH] APIs for adding/removing an addon repository (#3649) * APIs for adding/removing an addon repository * Misunderstood addons.store, fixed usage --- supervisor/api/__init__.py | 4 + supervisor/api/store.py | 17 ++ supervisor/api/supervisor.py | 26 +- supervisor/backups/backup.py | 2 +- supervisor/exceptions.py | 8 + .../resolution/fixups/store_execute_remove.py | 2 +- supervisor/store/__init__.py | 158 +++++++++--- supervisor/store/git.py | 26 +- supervisor/store/repository.py | 4 +- tests/api/test_store.py | 30 +++ tests/api/test_supervisor.py | 86 ++++++- tests/conftest.py | 6 + .../fixup/test_store_execute_remove.py | 2 +- tests/store/test_custom_repository.py | 239 +++++++++++++++--- tests/store/test_repository_git.py | 95 +++++++ 15 files changed, 585 insertions(+), 120 deletions(-) create mode 100644 tests/store/test_repository_git.py diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 4c3c0ad36..be6ee0595 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -539,6 +539,10 @@ class RestAPI(CoreSysAttributes): "/store/repositories/{repository}", api_store.repositories_repository_info, ), + web.post("/store/repositories", api_store.add_repository), + web.delete( + "/store/repositories/{repository}", api_store.remove_repository + ), ] ) diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 48d429966..c7abcbec7 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -35,6 +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 SCHEMA_UPDATE = vol.Schema( { @@ -42,6 +43,10 @@ SCHEMA_UPDATE = vol.Schema( } ) +SCHEMA_ADD_REPOSITORY = vol.Schema( + {vol.Required(ATTR_REPOSITORY): vol.All(str, validate_repository)} +) + class APIStore(CoreSysAttributes): """Handle RESTful API for store functions.""" @@ -173,3 +178,15 @@ class APIStore(CoreSysAttributes): """Return repository information.""" repository: Repository = self._extract_repository(request) return self._generate_repository_information(repository) + + @api_process + async def add_repository(self, request: web.Request): + """Add repository to the store.""" + body = await api_validate(SCHEMA_ADD_REPOSITORY, request) + await asyncio.shield(self.sys_store.add_repository(body[ATTR_REPOSITORY])) + + @api_process + async def remove_repository(self, request: web.Request): + """Remove repository from the store.""" + repository: Repository = self._extract_repository(request) + await asyncio.shield(self.sys_store.remove_repository(repository)) diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index e39c76dbc..acef7fc24 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -6,8 +6,6 @@ from typing import Any, Awaitable from aiohttp import web import voluptuous as vol -from supervisor.resolution.const import ContextType, SuggestionType - from ..const import ( ATTR_ADDONS, ATTR_ADDONS_REPOSITORIES, @@ -155,27 +153,15 @@ class APISupervisor(CoreSysAttributes): if ATTR_FORCE_SECURITY in body: self.sys_security.force = body[ATTR_FORCE_SECURITY] - if ATTR_ADDONS_REPOSITORIES in body: - new = set(body[ATTR_ADDONS_REPOSITORIES]) - await asyncio.shield(self.sys_store.update_repositories(new)) - - # Fix invalid repository - found_invalid = False - for suggestion in self.sys_resolution.suggestions: - if ( - suggestion.type != SuggestionType.EXECUTE_REMOVE - and suggestion.context != ContextType - ): - continue - found_invalid = True - await self.sys_resolution.apply_suggestion(suggestion) - - if found_invalid: - raise APIError("Invalid Add-on repository!") - + # Save changes before processing addons in case of errors self.sys_updater.save_data() self.sys_config.save_data() + if ATTR_ADDONS_REPOSITORIES in body: + await asyncio.shield( + self.sys_store.update_repositories(set(body[ATTR_ADDONS_REPOSITORIES])) + ) + await self.sys_resolution.evaluate.evaluate_system() @api_process diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 6b0d03c95..985ca19b1 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -508,7 +508,7 @@ class Backup(CoreSysAttributes): if not replace: new_list.update(self.sys_config.addons_repositories) - await self.sys_store.update_repositories(list(new_list)) + await self.sys_store.update_repositories(list(new_list), add_with_errors=True) def store_dockerconfig(self): """Store the configuration for Docker.""" diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 8c2dcbb6e..5b071cc4b 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -454,6 +454,10 @@ class StoreGitError(StoreError): """Raise if something on git is happening.""" +class StoreGitCloneError(StoreGitError): + """Raise if error occurred while cloning repository.""" + + class StoreNotFound(StoreError): """Raise if slug is not known.""" @@ -462,6 +466,10 @@ class StoreJobError(StoreError, JobException): """Raise on job error with git.""" +class StoreInvalidAddonRepo(StoreError): + """Raise on invalid addon repo.""" + + # Backup diff --git a/supervisor/resolution/fixups/store_execute_remove.py b/supervisor/resolution/fixups/store_execute_remove.py index 961fcdb9c..937579d07 100644 --- a/supervisor/resolution/fixups/store_execute_remove.py +++ b/supervisor/resolution/fixups/store_execute_remove.py @@ -56,4 +56,4 @@ class FixupStoreExecuteRemove(FixupBase): @property def auto(self) -> bool: """Return if a fixup can be apply as auto fix.""" - return True + return False diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index fe456f23d..aecfe7825 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -4,7 +4,14 @@ import logging from ..const import URL_HASSIO_ADDONS from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import StoreError, StoreGitError, StoreJobError, StoreNotFound +from ..exceptions import ( + StoreError, + StoreGitCloneError, + StoreGitError, + StoreInvalidAddonRepo, + StoreJobError, + StoreNotFound, +) from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from .addon import AddonStore @@ -53,7 +60,7 @@ class StoreManager(CoreSysAttributes): repositories = set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES # Init custom repositories and load add-ons - await self.update_repositories(repositories) + await self.update_repositories(repositories, add_with_errors=True) async def reload(self) -> None: """Update add-ons from repository and reload list.""" @@ -66,26 +73,50 @@ class StoreManager(CoreSysAttributes): self._read_addons() @Job(conditions=[JobCondition.INTERNET_SYSTEM]) - async def update_repositories(self, list_repositories): - """Add a new custom repository.""" - new_rep = set(list_repositories) - old_rep = {repository.source for repository in self.all} + async def add_repository( + self, url: str, *, persist: bool = True, add_with_errors: bool = False + ): + """Add a repository.""" + if url == URL_HASSIO_ADDONS: + url = StoreType.CORE - # add new repository - async def _add_repository(url: str): - """Add a repository.""" - if url == URL_HASSIO_ADDONS: - url = StoreType.CORE + repository = Repository(self.coresys, url) - repository = Repository(self.coresys, url) + if repository.slug in self.repositories: + raise StoreError(f"Can't add {url}, already in the store", _LOGGER.error) - # Load the repository - try: - await repository.load() - except StoreGitError: - _LOGGER.error("Can't load data from repository %s", url) - except StoreJobError: - _LOGGER.warning("Skip update to later for %s", repository.slug) + # Load the repository + try: + await repository.load() + except StoreGitCloneError as err: + _LOGGER.error("Can't retrieve data from %s due to %s", url, err) + if add_with_errors: + self.sys_resolution.create_issue( + IssueType.FATAL_ERROR, + ContextType.STORE, + reference=repository.slug, + suggestions=[SuggestionType.EXECUTE_REMOVE], + ) + else: + await repository.remove() + raise err + + except StoreGitError as err: + _LOGGER.error("Can't load data from repository %s due to %s", url, err) + if add_with_errors: + self.sys_resolution.create_issue( + IssueType.FATAL_ERROR, + ContextType.STORE, + reference=repository.slug, + suggestions=[SuggestionType.EXECUTE_RESET], + ) + else: + await repository.remove() + raise err + + except StoreJobError as err: + _LOGGER.error("Can't add repository %s due to %s", url, err) + if add_with_errors: self.sys_resolution.create_issue( IssueType.FATAL_ERROR, ContextType.STORE, @@ -93,7 +124,12 @@ class StoreManager(CoreSysAttributes): suggestions=[SuggestionType.EXECUTE_RELOAD], ) else: - if not repository.validate(): + await repository.remove() + raise err + + else: + if not repository.validate(): + if add_with_errors: _LOGGER.error("%s is not a valid add-on repository", url) self.sys_resolution.create_issue( IssueType.CORRUPT_REPOSITORY, @@ -101,34 +137,76 @@ class StoreManager(CoreSysAttributes): reference=repository.slug, suggestions=[SuggestionType.EXECUTE_REMOVE], ) + else: + await repository.remove() + raise StoreInvalidAddonRepo( + f"{url} is not a valid add-on repository", logger=_LOGGER.error + ) - # Add Repository to list - if repository.type == StoreType.GIT: - self.sys_config.add_addon_repository(repository.source) - self.repositories[repository.slug] = repository + # Add Repository to list + if repository.type == StoreType.GIT: + self.sys_config.add_addon_repository(repository.source) + self.repositories[repository.slug] = repository - repos = new_rep - old_rep - tasks = [self.sys_create_task(_add_repository(url)) for url in repos] - if tasks: - await asyncio.wait(tasks) + # Persist changes + if persist: + await self.data.update() + self._read_addons() + + async def remove_repository(self, repository: Repository, *, persist: bool = True): + """Remove a repository.""" + if repository.type != StoreType.GIT: + raise StoreInvalidAddonRepo( + "Can't remove built-in repositories!", logger=_LOGGER.error + ) + + if repository.slug in (addon.repository for addon in self.sys_addons.installed): + raise StoreError( + f"Can't remove '{repository.source}'. It's used by installed add-ons", + logger=_LOGGER.error, + ) + await self.repositories.pop(repository.slug).remove() + self.sys_config.drop_addon_repository(repository.url) + + if persist: + await self.data.update() + self._read_addons() + + @Job(conditions=[JobCondition.INTERNET_SYSTEM]) + async def update_repositories( + self, list_repositories, *, add_with_errors: bool = False + ): + """Add a new custom repository.""" + new_rep = set(list_repositories) + old_rep = {repository.source for repository in self.all} + + # Add new repositories + add_errors = await asyncio.gather( + *[ + self.add_repository(url, persist=False, add_with_errors=add_with_errors) + for url in new_rep - old_rep + ], + return_exceptions=True, + ) # Delete stale repositories - for url in old_rep - new_rep - BUILTIN_REPOSITORIES: - repository = self.get_from_url(url) - if repository.slug in ( - addon.repository for addon in self.sys_addons.installed - ): - raise StoreError( - f"Can't remove '{repository.source}'. It's used by installed add-ons", - logger=_LOGGER.error, - ) - await self.repositories.pop(repository.slug).remove() - self.sys_config.drop_addon_repository(url) + remove_errors = await asyncio.gather( + *[ + self.remove_repository(self.get_from_url(url), persist=False) + for url in old_rep - new_rep - BUILTIN_REPOSITORIES + ], + return_exceptions=True, + ) - # update data + # Always update data, even there are errors, some changes may have succeeded await self.data.update() self._read_addons() + # Raise the first error we found (if any) + for error in add_errors + remove_errors: + if error: + raise error + def _read_addons(self) -> None: """Reload add-ons inside store.""" all_addons = set(self.data.addons) diff --git a/supervisor/store/git.py b/supervisor/store/git.py index d085b5c0d..4241d7d8e 100644 --- a/supervisor/store/git.py +++ b/supervisor/store/git.py @@ -9,7 +9,7 @@ import git from ..const import ATTR_BRANCH, ATTR_URL, URL_HASSIO_ADDONS from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import StoreGitError, StoreJobError +from ..exceptions import StoreGitCloneError, StoreGitError, StoreJobError from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils import remove_folder @@ -65,12 +65,6 @@ class GitRepo(CoreSysAttributes): git.GitCommandError, ) as err: _LOGGER.error("Can't load %s", self.path) - self.sys_resolution.create_issue( - IssueType.FATAL_ERROR, - ContextType.STORE, - reference=self.path.stem, - suggestions=[SuggestionType.EXECUTE_RESET], - ) raise StoreGitError() from err # Fix possible corruption @@ -80,12 +74,6 @@ class GitRepo(CoreSysAttributes): await self.sys_run_in_executor(self.repo.git.execute, ["git", "fsck"]) except git.GitCommandError as err: _LOGGER.error("Integrity check on %s failed: %s.", self.path, err) - self.sys_resolution.create_issue( - IssueType.CORRUPT_REPOSITORY, - ContextType.STORE, - reference=self.path.stem, - suggestions=[SuggestionType.EXECUTE_RESET], - ) raise StoreGitError() from err @Job( @@ -120,17 +108,7 @@ class GitRepo(CoreSysAttributes): git.GitCommandError, ) as err: _LOGGER.error("Can't clone %s repository: %s.", self.url, err) - self.sys_resolution.create_issue( - IssueType.FATAL_ERROR, - ContextType.STORE, - reference=self.path.stem, - suggestions=[ - SuggestionType.EXECUTE_RELOAD - if self.builtin - else SuggestionType.EXECUTE_REMOVE - ], - ) - raise StoreGitError() from err + raise StoreGitCloneError() from err @Job( conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM], diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index 3e3c640d1..f960e4282 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -10,7 +10,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ConfigurationFileError, StoreError from ..utils.common import read_json_or_yaml_file from .const import StoreType -from .git import GitRepoCustom, GitRepoHassIO +from .git import GitRepo, GitRepoCustom, GitRepoHassIO from .utils import get_hash_from_repository from .validate import SCHEMA_REPOSITORY_CONFIG @@ -24,7 +24,7 @@ class Repository(CoreSysAttributes): def __init__(self, coresys: CoreSys, repository: str): """Initialize repository object.""" self.coresys: CoreSys = coresys - self.git: Optional[str] = None + self.git: Optional[GitRepo] = None self.source: str = repository if repository == StoreType.LOCAL: diff --git a/tests/api/test_store.py b/tests/api/test_store.py index e38d67d69..c38845c17 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -1,10 +1,15 @@ """Test Store API.""" +from unittest.mock import patch + from aiohttp.test_utils import TestClient import pytest +from supervisor.coresys import CoreSys from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository +REPO_URL = "https://github.com/awesome-developer/awesome-repo" + @pytest.mark.asyncio async def test_api_store( @@ -65,3 +70,28 @@ async def test_api_store_repositories_repository( result = await resp.json() assert result["data"]["slug"] == repository.slug + + +async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys): + """Test POST /store/repositories REST API.""" + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( + "supervisor.store.repository.Repository.validate", return_value=True + ): + response = await api_client.post( + "/store/repositories", json={"repository": REPO_URL} + ) + + assert response.status == 200 + assert REPO_URL in coresys.config.addons_repositories + assert isinstance(coresys.store.get_from_url(REPO_URL), Repository) + + +async def test_api_store_remove_repository( + api_client: TestClient, coresys: CoreSys, repository: Repository +): + """Test DELETE /store/repositories/{repository} REST API.""" + 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.slug not in coresys.store.repositories diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 8fec74784..0807267e2 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -1,15 +1,99 @@ """Test Supervisor API.""" # pylint: disable=protected-access +from unittest.mock import patch + +from aiohttp.test_utils import TestClient import pytest from supervisor.coresys import CoreSys +from supervisor.exceptions import StoreGitError, StoreNotFound +from supervisor.store.repository import Repository + +REPO_URL = "https://github.com/awesome-developer/awesome-repo" @pytest.mark.asyncio -async def test_api_supervisor_options_debug(api_client, coresys: CoreSys): +async def test_api_supervisor_options_debug(api_client: TestClient, coresys: CoreSys): """Test security options force security.""" assert not coresys.config.debug await api_client.post("/supervisor/options", json={"debug": True}) assert coresys.config.debug + + +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 + with pytest.raises(StoreNotFound): + coresys.store.get_from_url(REPO_URL) + + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( + "supervisor.store.repository.Repository.validate", return_value=True + ): + response = await api_client.post( + "/supervisor/options", json={"addons_repositories": [REPO_URL]} + ) + + assert response.status == 200 + assert REPO_URL in coresys.config.addons_repositories + assert isinstance(coresys.store.get_from_url(REPO_URL), Repository) + + +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.slug in coresys.store.repositories + + response = await api_client.post( + "/supervisor/options", json={"addons_repositories": []} + ) + + assert response.status == 200 + assert repository.url not in coresys.config.addons_repositories + assert repository.slug not in coresys.store.repositories + + +@pytest.mark.parametrize("git_error", [None, StoreGitError()]) +async def test_api_supervisor_options_repositories_skipped_on_error( + api_client: TestClient, coresys: CoreSys, git_error: StoreGitError +): + """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): + 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 + with pytest.raises(StoreNotFound): + coresys.store.get_from_url(REPO_URL) + + +async def test_api_supervisor_options_repo_error_with_config_change( + api_client: TestClient, coresys: CoreSys +): + """Test config change with add repository error via POST /supervisor/options REST API.""" + assert not coresys.config.debug + + with patch( + "supervisor.store.repository.Repository.load", side_effect=StoreGitError() + ): + response = await api_client.post( + "/supervisor/options", + json={"debug": True, "addons_repositories": [REPO_URL]}, + ) + + assert response.status == 400 + assert REPO_URL not in coresys.config.addons_repositories + + assert coresys.config.debug + coresys.updater.save_data.assert_called_once() + coresys.config.save_data.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py index e7b6ce34d..80466c919 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -316,12 +316,18 @@ def store_addon(coresys: CoreSys, tmp_path, repository): async def repository(coresys: CoreSys): """Repository fixture.""" coresys.config.drop_addon_repository("https://github.com/hassio-addons/repository") + coresys.config.drop_addon_repository( + "https://github.com/esphome/home-assistant-addon" + ) await coresys.store.load() repository_obj = Repository( coresys, "https://github.com/awesome-developer/awesome-repo" ) coresys.store.repositories[repository_obj.slug] = repository_obj + coresys.config.add_addon_repository( + "https://github.com/awesome-developer/awesome-repo" + ) yield repository_obj diff --git a/tests/resolution/fixup/test_store_execute_remove.py b/tests/resolution/fixup/test_store_execute_remove.py index 4139f4c3b..86714245f 100644 --- a/tests/resolution/fixup/test_store_execute_remove.py +++ b/tests/resolution/fixup/test_store_execute_remove.py @@ -12,7 +12,7 @@ async def test_fixup(coresys: CoreSys): """Test fixup.""" store_execute_remove = FixupStoreExecuteRemove(coresys) - assert store_execute_remove.auto + assert store_execute_remove.auto is False coresys.resolution.suggestions = Suggestion( SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="test" diff --git a/tests/store/test_custom_repository.py b/tests/store/test_custom_repository.py index aaa707322..d6fd07cef 100644 --- a/tests/store/test_custom_repository.py +++ b/tests/store/test_custom_repository.py @@ -5,70 +5,178 @@ from unittest.mock import patch import pytest from supervisor.addons.addon import Addon -from supervisor.exceptions import StoreError +from supervisor.coresys import CoreSys +from supervisor.exceptions import ( + StoreError, + StoreGitCloneError, + StoreGitError, + StoreNotFound, +) from supervisor.resolution.const import SuggestionType -from supervisor.store import BUILTIN_REPOSITORIES +from supervisor.store import BUILTIN_REPOSITORIES, StoreManager +from supervisor.store.addon import AddonStore +from supervisor.store.repository import Repository -@pytest.mark.asyncio -async def test_add_valid_repository(coresys, store_manager): +@pytest.mark.parametrize("use_update", [True, False]) +async def test_add_valid_repository( + coresys: CoreSys, store_manager: StoreManager, use_update: bool +): """Test add custom repository.""" current = coresys.config.addons_repositories 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): + if use_update: + await store_manager.update_repositories(current + ["http://example.com"]) + else: + await store_manager.add_repository("http://example.com") - await store_manager.update_repositories(current + ["http://example.com"]) assert store_manager.get_from_url("http://example.com").validate() + assert "http://example.com" in coresys.config.addons_repositories -@pytest.mark.asyncio -async def test_add_valid_repository_url(coresys, store_manager): - """Test add custom repository.""" - current = coresys.config.addons_repositories - 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(current + ["http://example.com"]) - assert store_manager.get_from_url("http://example.com").validate() - assert "http://example.com" in coresys.config.addons_repositories - - -@pytest.mark.asyncio -async def test_add_invalid_repository(coresys, store_manager): - """Test add custom repository.""" +@pytest.mark.parametrize("use_update", [True, False]) +async def test_add_invalid_repository( + coresys: CoreSys, store_manager: StoreManager, use_update: bool +): + """Test add invalid custom repository.""" current = coresys.config.addons_repositories with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value="", ): - await store_manager.update_repositories(current + ["http://example.com"]) + if use_update: + await store_manager.update_repositories( + current + ["http://example.com"], add_with_errors=True + ) + else: + await store_manager.add_repository( + "http://example.com", add_with_errors=True + ) + assert not store_manager.get_from_url("http://example.com").validate() assert "http://example.com" in coresys.config.addons_repositories assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE -@pytest.mark.asyncio -async def test_add_invalid_repository_file(coresys, store_manager): - """Test add custom repository.""" +@pytest.mark.parametrize("use_update", [True, False]) +async def test_error_on_invalid_repository( + coresys: CoreSys, store_manager: StoreManager, use_update +): + """Test invalid repository not added.""" + current = coresys.config.addons_repositories + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( + "pathlib.Path.read_text", + return_value="", + ), pytest.raises(StoreError): + if use_update: + await store_manager.update_repositories(current + ["http://example.com"]) + else: + await store_manager.add_repository("http://example.com") + + assert "http://example.com" not in coresys.config.addons_repositories + assert len(coresys.resolution.suggestions) == 0 + with pytest.raises(StoreNotFound): + store_manager.get_from_url("http://example.com") + + +@pytest.mark.parametrize("use_update", [True, False]) +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 with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value=json.dumps({"name": "Awesome repository"}), ), patch("pathlib.Path.exists", return_value=False): - await store_manager.update_repositories(current + ["http://example.com"]) + if use_update: + await store_manager.update_repositories( + current + ["http://example.com"], add_with_errors=True + ) + else: + await store_manager.add_repository( + "http://example.com", add_with_errors=True + ) + assert not store_manager.get_from_url("http://example.com").validate() assert "http://example.com" in coresys.config.addons_repositories assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE +@pytest.mark.parametrize( + "use_update,git_error,suggestion_type", + [ + (True, StoreGitCloneError(), SuggestionType.EXECUTE_REMOVE), + (True, StoreGitError(), SuggestionType.EXECUTE_RESET), + (False, StoreGitCloneError(), SuggestionType.EXECUTE_REMOVE), + (False, StoreGitError(), SuggestionType.EXECUTE_RESET), + ], +) +async def test_add_repository_with_git_error( + coresys: CoreSys, + store_manager: StoreManager, + use_update: bool, + git_error: StoreGitError, + suggestion_type: SuggestionType, +): + """Test repo added with issue on git error.""" + current = coresys.config.addons_repositories + with patch("supervisor.store.repository.Repository.load", side_effect=git_error): + if use_update: + await store_manager.update_repositories( + current + ["http://example.com"], add_with_errors=True + ) + else: + await store_manager.add_repository( + "http://example.com", add_with_errors=True + ) + + assert "http://example.com" in coresys.config.addons_repositories + assert coresys.resolution.suggestions[-1].type == suggestion_type + assert isinstance(store_manager.get_from_url("http://example.com"), Repository) + + +@pytest.mark.parametrize( + "use_update,git_error", + [ + (True, StoreGitCloneError()), + (True, StoreGitError()), + (False, StoreGitCloneError()), + (False, StoreGitError()), + ], +) +async def test_error_on_repository_with_git_error( + coresys: CoreSys, + store_manager: StoreManager, + use_update: bool, + git_error: StoreGitError, +): + """Test repo not added on git error.""" + current = coresys.config.addons_repositories + with patch( + "supervisor.store.repository.Repository.load", side_effect=git_error + ), pytest.raises(StoreError): + if use_update: + await store_manager.update_repositories(current + ["http://example.com"]) + else: + await store_manager.add_repository("http://example.com") + + assert "http://example.com" not in coresys.config.addons_repositories + assert len(coresys.resolution.suggestions) == 0 + with pytest.raises(StoreNotFound): + store_manager.get_from_url("http://example.com") + + @pytest.mark.asyncio -async def test_preinstall_valid_repository(coresys, store_manager): +async def test_preinstall_valid_repository( + coresys: CoreSys, store_manager: StoreManager +): """Test add core repository valid.""" with patch("supervisor.store.repository.Repository.load", return_value=None): await store_manager.update_repositories(BUILTIN_REPOSITORIES) @@ -76,15 +184,86 @@ async def test_preinstall_valid_repository(coresys, store_manager): assert store_manager.get("local").validate() -@pytest.mark.asyncio -async def test_remove_used_repository(coresys, store_manager, store_addon): +@pytest.mark.parametrize("use_update", [True, False]) +async def test_remove_repository( + coresys: CoreSys, + store_manager: StoreManager, + repository: Repository, + use_update: bool, +): + """Test removing a custom repository.""" + assert repository.url in coresys.config.addons_repositories + assert repository.slug in coresys.store.repositories + + if use_update: + await store_manager.update_repositories([]) + else: + await store_manager.remove_repository(repository) + + assert repository.url not in coresys.config.addons_repositories + assert repository.slug not in coresys.addons.store + assert repository.slug not in coresys.store.repositories + + +@pytest.mark.parametrize("use_update", [True, False]) +async def test_remove_used_repository( + coresys: CoreSys, + store_manager: StoreManager, + store_addon: AddonStore, + use_update: bool, +): """Test removing used custom repository.""" coresys.addons.data.install(store_addon) addon = Addon(coresys, store_addon.slug) coresys.addons.local[addon.slug] = addon + assert store_addon.repository in coresys.store.repositories + with pytest.raises( StoreError, match="Can't remove 'https://github.com/awesome-developer/awesome-repo'. It's used by installed add-ons", ): - await store_manager.update_repositories([]) + if use_update: + await store_manager.update_repositories([]) + else: + await store_manager.remove_repository( + coresys.store.repositories[store_addon.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) + + store_manager.data.update.assert_called_once() + store_manager.data.update.reset_mock() + + with patch( + "supervisor.store.repository.Repository.load", + side_effect=[None, StoreGitError()], + ), pytest.raises(StoreError): + await store_manager.update_repositories( + current + ["http://example.com", "http://example2.com"] + ) + + assert len(coresys.config.addons_repositories) == initial + 1 + store_manager.data.update.assert_called_once() + + +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 + with patch( + "supervisor.store.repository.Repository.validate", return_value=True + ), patch( + "supervisor.store.repository.Repository.load", return_value=None + ), pytest.raises( + StoreError + ): + await store_manager.add_repository(repository.url) diff --git a/tests/store/test_repository_git.py b/tests/store/test_repository_git.py new file mode 100644 index 000000000..52afc9c0e --- /dev/null +++ b/tests/store/test_repository_git.py @@ -0,0 +1,95 @@ +"""Test git repository.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from git import GitCommandError, GitError, InvalidGitRepositoryError, NoSuchPathError +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import StoreGitCloneError, StoreGitError +from supervisor.store.git import GitRepo + +REPO_URL = "https://github.com/awesome-developer/awesome-repo" + + +@pytest.fixture(name="clone_from") +async def fixture_clone_from(): + """Mock git clone_from.""" + with patch("git.Repo.clone_from") as clone_from: + yield clone_from + + +@pytest.mark.parametrize("branch", [None, "dev"]) +async def test_git_clone( + coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, branch: str | None +): + """Test git clone.""" + fragment = f"#{branch}" if branch else "" + repo = GitRepo(coresys, tmp_path, f"{REPO_URL}{fragment}") + + await repo.clone.__wrapped__(repo) + + kwargs = {"recursive": True, "depth": 1, "shallow-submodules": True} + if branch: + kwargs["branch"] = branch + + clone_from.assert_called_once_with( + REPO_URL, + str(tmp_path), + **kwargs, + ) + + +@pytest.mark.parametrize( + "git_error", + [InvalidGitRepositoryError(), NoSuchPathError(), GitCommandError("clone")], +) +async def test_git_clone_error( + coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, git_error: GitError +): + """Test git clone error.""" + repo = GitRepo(coresys, tmp_path, REPO_URL) + + clone_from.side_effect = git_error + with pytest.raises(StoreGitCloneError): + await repo.clone.__wrapped__(repo) + + assert len(coresys.resolution.suggestions) == 0 + + +async def test_git_load(coresys: CoreSys, tmp_path: Path): + """Test git load.""" + repo = GitRepo(coresys, tmp_path, REPO_URL) + + with patch("pathlib.Path.is_dir", return_value=True), patch.object( + GitRepo, "sys_run_in_executor", new_callable=AsyncMock + ) as run_in_executor: + await repo.load() + + assert run_in_executor.call_count == 2 + + +@pytest.mark.parametrize( + "git_errors", + [ + InvalidGitRepositoryError(), + NoSuchPathError(), + GitCommandError("init"), + [AsyncMock(), GitCommandError("fsck")], + ], +) +async def test_git_load_error( + coresys: CoreSys, tmp_path: Path, git_errors: GitError | list[GitError | None] +): + """Test git load error.""" + repo = GitRepo(coresys, tmp_path, REPO_URL) + + with patch("pathlib.Path.is_dir", return_value=True), patch.object( + GitRepo, "sys_run_in_executor", new_callable=AsyncMock + ) as run_in_executor, pytest.raises(StoreGitError): + run_in_executor.side_effect = git_errors + await repo.load() + + assert len(coresys.resolution.suggestions) == 0