From d8101ddba8ba093aa89289f08a00cbe35b05a8ed Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 17 Dec 2024 05:18:32 -0500 Subject: [PATCH] Use status 404 in more places when appropriate (#5480) --- supervisor/api/addons.py | 3 +- supervisor/api/discovery.py | 4 +- supervisor/api/docker.py | 4 ++ supervisor/api/jobs.py | 13 ++++-- supervisor/api/mounts.py | 27 +++++++----- supervisor/api/network.py | 4 +- supervisor/api/resolution.py | 82 ++++++++++++++++++------------------ supervisor/api/services.py | 4 +- supervisor/api/store.py | 35 ++++++++------- tests/api/test_addons.py | 71 ++++++++++++++++++++++++++++++- tests/api/test_discovery.py | 12 ++++++ tests/api/test_docker.py | 11 ++++- tests/api/test_jobs.py | 17 +++++++- tests/api/test_mounts.py | 63 +++++++-------------------- tests/api/test_network.py | 19 +++++++++ tests/api/test_resolution.py | 59 ++++++++++++++++++++++---- tests/api/test_services.py | 16 +++++++ tests/api/test_store.py | 73 +++++++++++++++++++++++++++++++- 18 files changed, 376 insertions(+), 141 deletions(-) create mode 100644 tests/api/test_services.py diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 6c9cad1cce6..cb657f6b713 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -106,6 +106,7 @@ APIAddonNotInstalled, APIError, APIForbidden, + APINotFound, PwnedError, PwnedSecret, ) @@ -161,7 +162,7 @@ def get_addon_for_request(self, request: web.Request) -> Addon: addon = self.sys_addons.get(addon_slug) if not addon: - raise APIError(f"Addon {addon_slug} does not exist") + raise APINotFound(f"Addon {addon_slug} does not exist") if not isinstance(addon, Addon) or not addon.is_installed: raise APIAddonNotInstalled("Addon is not installed") diff --git a/supervisor/api/discovery.py b/supervisor/api/discovery.py index 26cbc9bd68c..c3a994db59a 100644 --- a/supervisor/api/discovery.py +++ b/supervisor/api/discovery.py @@ -16,7 +16,7 @@ AddonState, ) from ..coresys import CoreSysAttributes -from ..exceptions import APIError, APIForbidden +from ..exceptions import APIForbidden, APINotFound from .utils import api_process, api_validate, require_home_assistant _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def _extract_message(self, request): """Extract discovery message from URL.""" message = self.sys_discovery.get(request.match_info.get("uuid")) if not message: - raise APIError("Discovery message not found") + raise APINotFound("Discovery message not found") return message @api_process diff --git a/supervisor/api/docker.py b/supervisor/api/docker.py index 642ad1e468b..dfa6c849e9c 100644 --- a/supervisor/api/docker.py +++ b/supervisor/api/docker.py @@ -16,6 +16,7 @@ ATTR_VERSION, ) from ..coresys import CoreSysAttributes +from ..exceptions import APINotFound from .utils import api_process, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -58,6 +59,9 @@ async def create_registry(self, request: web.Request): async def remove_registry(self, request: web.Request): """Delete a docker registry.""" hostname = request.match_info.get(ATTR_HOSTNAME) + if hostname not in self.sys_docker.config.registries: + raise APINotFound(f"Hostname {hostname} does not exist in registries") + del self.sys_docker.config.registries[hostname] self.sys_docker.config.save_data() diff --git a/supervisor/api/jobs.py b/supervisor/api/jobs.py index 0b95397515d..dc9365541bd 100644 --- a/supervisor/api/jobs.py +++ b/supervisor/api/jobs.py @@ -7,7 +7,7 @@ import voluptuous as vol from ..coresys import CoreSysAttributes -from ..exceptions import APIError +from ..exceptions import APIError, APINotFound, JobNotFound from ..jobs import SupervisorJob from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition from .const import ATTR_JOBS @@ -23,6 +23,13 @@ class APIJobs(CoreSysAttributes): """Handle RESTful API for OS functions.""" + def _extract_job(self, request: web.Request) -> SupervisorJob: + """Extract job from request or raise.""" + try: + return self.sys_jobs.get_job(request.match_info.get("uuid")) + except JobNotFound: + raise APINotFound("Job does not exist") from None + def _list_jobs(self, start: SupervisorJob | None = None) -> list[dict[str, Any]]: """Return current job tree.""" jobs_by_parent: dict[str | None, list[SupervisorJob]] = {} @@ -86,13 +93,13 @@ async def reset(self, request: web.Request) -> None: @api_process async def job_info(self, request: web.Request) -> dict[str, Any]: """Get details of a job by ID.""" - job = self.sys_jobs.get_job(request.match_info.get("uuid")) + job = self._extract_job(request) return self._list_jobs(job)[0] @api_process async def remove_job(self, request: web.Request) -> None: """Remove a completed job.""" - job = self.sys_jobs.get_job(request.match_info.get("uuid")) + job = self._extract_job(request) if not job.done: raise APIError(f"Job {job.uuid} is not done!") diff --git a/supervisor/api/mounts.py b/supervisor/api/mounts.py index b00f15781f0..8567580f8e0 100644 --- a/supervisor/api/mounts.py +++ b/supervisor/api/mounts.py @@ -7,7 +7,7 @@ from ..const import ATTR_NAME, ATTR_STATE from ..coresys import CoreSysAttributes -from ..exceptions import APIError +from ..exceptions import APIError, APINotFound from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage from ..mounts.mount import Mount from ..mounts.validate import SCHEMA_MOUNT_CONFIG @@ -24,6 +24,13 @@ class APIMounts(CoreSysAttributes): """Handle REST API for mounting options.""" + def _extract_mount(self, request: web.Request) -> Mount: + """Extract mount from request or raise.""" + name = request.match_info.get("mount") + if name not in self.sys_mounts: + raise APINotFound(f"No mount exists with name {name}") + return self.sys_mounts.get(name) + @api_process async def info(self, request: web.Request) -> dict[str, Any]: """Return MountManager info.""" @@ -85,15 +92,13 @@ async def create_mount(self, request: web.Request) -> None: @api_process async def update_mount(self, request: web.Request) -> None: """Update an existing mount in supervisor.""" - name = request.match_info.get("mount") + current = self._extract_mount(request) name_schema = vol.Schema( - {vol.Optional(ATTR_NAME, default=name): name}, extra=vol.ALLOW_EXTRA + {vol.Optional(ATTR_NAME, default=current.name): current.name}, + extra=vol.ALLOW_EXTRA, ) body = await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request) - if name not in self.sys_mounts: - raise APIError(f"No mount exists with name {name}") - mount = Mount.from_dict(self.coresys, body) await self.sys_mounts.create_mount(mount) @@ -110,8 +115,8 @@ async def update_mount(self, request: web.Request) -> None: @api_process async def delete_mount(self, request: web.Request) -> None: """Delete an existing mount in supervisor.""" - name = request.match_info.get("mount") - mount = await self.sys_mounts.remove_mount(name) + current = self._extract_mount(request) + mount = await self.sys_mounts.remove_mount(current.name) # If it was a backup mount, reload backups if mount.usage == MountUsage.BACKUP: @@ -122,9 +127,9 @@ async def delete_mount(self, request: web.Request) -> None: @api_process async def reload_mount(self, request: web.Request) -> None: """Reload an existing mount in supervisor.""" - name = request.match_info.get("mount") - await self.sys_mounts.reload_mount(name) + mount = self._extract_mount(request) + await self.sys_mounts.reload_mount(mount.name) # If it's a backup mount, reload backups - if self.sys_mounts.get(name).usage == MountUsage.BACKUP: + if mount.usage == MountUsage.BACKUP: self.sys_create_task(self.sys_backups.reload()) diff --git a/supervisor/api/network.py b/supervisor/api/network.py index bc8c8d24da2..f9374923644 100644 --- a/supervisor/api/network.py +++ b/supervisor/api/network.py @@ -42,7 +42,7 @@ DOCKER_NETWORK_MASK, ) from ..coresys import CoreSysAttributes -from ..exceptions import APIError, HostNetworkNotFound +from ..exceptions import APIError, APINotFound, HostNetworkNotFound from ..host.configuration import ( AccessPoint, Interface, @@ -167,7 +167,7 @@ def _get_interface(self, name: str) -> Interface: except HostNetworkNotFound: pass - raise APIError(f"Interface {name} does not exist") from None + raise APINotFound(f"Interface {name} does not exist") from None @api_process async def info(self, request: web.Request) -> dict[str, Any]: diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index 8968b44c796..f08971e6f3a 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -19,8 +19,9 @@ ATTR_UNSUPPORTED, ) from ..coresys import CoreSysAttributes -from ..exceptions import APIError, ResolutionNotFound -from ..resolution.data import Suggestion +from ..exceptions import APINotFound, ResolutionNotFound +from ..resolution.checks.base import CheckBase +from ..resolution.data import Issue, Suggestion from .utils import api_process, api_validate SCHEMA_CHECK_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLED): bool}) @@ -29,6 +30,29 @@ class APIResoulution(CoreSysAttributes): """Handle REST API for resoulution.""" + def _extract_issue(self, request: web.Request) -> Issue: + """Extract issue from request or raise.""" + try: + return self.sys_resolution.get_issue(request.match_info.get("issue")) + except ResolutionNotFound: + raise APINotFound("The supplied UUID is not a valid issue") from None + + def _extract_suggestion(self, request: web.Request) -> Suggestion: + """Extract suggestion from request or raise.""" + try: + return self.sys_resolution.get_suggestion( + request.match_info.get("suggestion") + ) + except ResolutionNotFound: + raise APINotFound("The supplied UUID is not a valid suggestion") from None + + def _extract_check(self, request: web.Request) -> CheckBase: + """Extract check from request or raise.""" + try: + return self.sys_resolution.check.get(request.match_info.get("check")) + except ResolutionNotFound: + raise APINotFound("The supplied check slug is not available") from None + def _generate_suggestion_information(self, suggestion: Suggestion): """Generate suggestion information for response.""" resp = attr.asdict(suggestion) @@ -61,47 +85,31 @@ async def info(self, request: web.Request) -> dict[str, Any]: @api_process async def apply_suggestion(self, request: web.Request) -> None: """Apply suggestion.""" - try: - suggestion = self.sys_resolution.get_suggestion( - request.match_info.get("suggestion") - ) - await self.sys_resolution.apply_suggestion(suggestion) - except ResolutionNotFound: - raise APIError("The supplied UUID is not a valid suggestion") from None + suggestion = self._extract_suggestion(request) + await self.sys_resolution.apply_suggestion(suggestion) @api_process async def dismiss_suggestion(self, request: web.Request) -> None: """Dismiss suggestion.""" - try: - suggestion = self.sys_resolution.get_suggestion( - request.match_info.get("suggestion") - ) - self.sys_resolution.dismiss_suggestion(suggestion) - except ResolutionNotFound: - raise APIError("The supplied UUID is not a valid suggestion") from None + suggestion = self._extract_suggestion(request) + self.sys_resolution.dismiss_suggestion(suggestion) @api_process async def suggestions_for_issue(self, request: web.Request) -> dict[str, Any]: """Return suggestions that fix an issue.""" - try: - issue = self.sys_resolution.get_issue(request.match_info.get("issue")) - return { - ATTR_SUGGESTIONS: [ - self._generate_suggestion_information(suggestion) - for suggestion in self.sys_resolution.suggestions_for_issue(issue) - ] - } - except ResolutionNotFound: - raise APIError("The supplied UUID is not a valid issue") from None + issue = self._extract_issue(request) + return { + ATTR_SUGGESTIONS: [ + self._generate_suggestion_information(suggestion) + for suggestion in self.sys_resolution.suggestions_for_issue(issue) + ] + } @api_process async def dismiss_issue(self, request: web.Request) -> None: """Dismiss issue.""" - try: - issue = self.sys_resolution.get_issue(request.match_info.get("issue")) - self.sys_resolution.dismiss_issue(issue) - except ResolutionNotFound: - raise APIError("The supplied UUID is not a valid issue") from None + issue = self._extract_issue(request) + self.sys_resolution.dismiss_issue(issue) @api_process def healthcheck(self, request: web.Request) -> Awaitable[None]: @@ -112,11 +120,7 @@ def healthcheck(self, request: web.Request) -> Awaitable[None]: async def options_check(self, request: web.Request) -> None: """Set options for check.""" body = await api_validate(SCHEMA_CHECK_OPTIONS, request) - - try: - check = self.sys_resolution.check.get(request.match_info.get("check")) - except ResolutionNotFound: - raise APIError("The supplied check slug is not available") from None + check = self._extract_check(request) # Apply options if ATTR_ENABLED in body: @@ -127,9 +131,5 @@ async def options_check(self, request: web.Request) -> None: @api_process async def run_check(self, request: web.Request) -> None: """Execute a backend check.""" - try: - check = self.sys_resolution.check.get(request.match_info.get("check")) - except ResolutionNotFound: - raise APIError("The supplied check slug is not available") from None - + check = self._extract_check(request) await check() diff --git a/supervisor/api/services.py b/supervisor/api/services.py index 6a9428d5f1c..60f61bb158d 100644 --- a/supervisor/api/services.py +++ b/supervisor/api/services.py @@ -9,7 +9,7 @@ REQUEST_FROM, ) from ..coresys import CoreSysAttributes -from ..exceptions import APIError, APIForbidden +from ..exceptions import APIError, APIForbidden, APINotFound from .utils import api_process, api_validate @@ -20,7 +20,7 @@ def _extract_service(self, request): """Return service, throw an exception if it doesn't exist.""" service = self.sys_services.get(request.match_info.get("service")) if not service: - raise APIError("Service does not exist") + raise APINotFound("Service does not exist") return service diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 4dc4e2747bf..82e14627f55 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -51,7 +51,7 @@ REQUEST_FROM, ) from ..coresys import CoreSysAttributes -from ..exceptions import APIError, APIForbidden +from ..exceptions import APIError, APIForbidden, APINotFound from ..store.addon import AddonStore from ..store.repository import Repository from ..store.validate import validate_repository @@ -74,19 +74,17 @@ class APIStore(CoreSysAttributes): def _extract_addon(self, request: web.Request, installed=False) -> AnyAddon: """Return add-on, throw an exception it it doesn't exist.""" addon_slug: str = request.match_info.get("addon") - addon_version: str = request.match_info.get("version", "latest") - - if installed: - addon = self.sys_addons.local.get(addon_slug) - if addon is None or not addon.is_installed: - raise APIError(f"Addon {addon_slug} is not installed") - else: - addon = self.sys_addons.store.get(addon_slug) - - if not addon: - raise APIError( - f"Addon {addon_slug} with version {addon_version} does not exist in the store" - ) + + if not (addon := self.sys_addons.get(addon_slug)): + raise APINotFound(f"Addon {addon_slug} does not exist") + + if installed and not addon.is_installed: + raise APIError(f"Addon {addon_slug} is not installed") + + if not installed and addon.is_installed: + if not addon.addon_store: + raise APINotFound(f"Addon {addon_slug} does not exist in the store") + return addon.addon_store return addon @@ -94,11 +92,12 @@ def _extract_repository(self, request: web.Request) -> Repository: """Return repository, throw an exception it it doesn't exist.""" repository_slug: str = request.match_info.get("repository") - repository = self.sys_store.get(repository_slug) - if not repository: - raise APIError(f"Repository {repository_slug} does not exist in the store") + if repository_slug not in self.sys_store.repositories: + raise APINotFound( + f"Repository {repository_slug} does not exist in the store" + ) - return repository + return self.sys_store.get(repository_slug) def _generate_addon_information( self, addon: AddonStore, extended: bool = False diff --git a/tests/api/test_addons.py b/tests/api/test_addons.py index 1e7ed79c4dd..b27e40dca88 100644 --- a/tests/api/test_addons.py +++ b/tests/api/test_addons.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import MagicMock, PropertyMock, patch +from aiohttp import ClientResponse from aiohttp.test_utils import TestClient import pytest @@ -82,7 +83,7 @@ async def test_api_addon_logs_not_installed(api_client: TestClient): """Test error is returned for non-existing add-on.""" resp = await api_client.get("/addons/hic_sunt_leones/logs") - assert resp.status == 400 + assert resp.status == 404 assert resp.content_type == "text/plain" content = await resp.text() assert content == "Addon hic_sunt_leones does not exist" @@ -366,3 +367,71 @@ async def test_addon_options_boot_mode_manual_only_invalid( body["message"] == "Addon local_example boot option is set to manual_only so it cannot be changed" ) + + +async def get_message(resp: ClientResponse, json_expected: bool) -> str: + """Get message from response based on response type.""" + if json_expected: + body = await resp.json() + return body["message"] + return await resp.text() + + +@pytest.mark.parametrize( + ("method", "url", "json_expected"), + [ + ("get", "/addons/bad/info", True), + ("post", "/addons/bad/uninstall", True), + ("post", "/addons/bad/start", True), + ("post", "/addons/bad/stop", True), + ("post", "/addons/bad/restart", True), + ("post", "/addons/bad/options", True), + ("post", "/addons/bad/sys_options", True), + ("post", "/addons/bad/options/validate", True), + ("post", "/addons/bad/rebuild", True), + ("post", "/addons/bad/stdin", True), + ("post", "/addons/bad/security", True), + ("get", "/addons/bad/stats", True), + ("get", "/addons/bad/logs", False), + ("get", "/addons/bad/logs/follow", False), + ("get", "/addons/bad/logs/boots/1", False), + ("get", "/addons/bad/logs/boots/1/follow", False), + ], +) +async def test_addon_not_found( + api_client: TestClient, method: str, url: str, json_expected: bool +): + """Test addon not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + assert await get_message(resp, json_expected) == "Addon bad does not exist" + + +@pytest.mark.parametrize( + ("method", "url", "json_expected"), + [ + ("post", "/addons/local_ssh/uninstall", True), + ("post", "/addons/local_ssh/start", True), + ("post", "/addons/local_ssh/stop", True), + ("post", "/addons/local_ssh/restart", True), + ("post", "/addons/local_ssh/options", True), + ("post", "/addons/local_ssh/sys_options", True), + ("post", "/addons/local_ssh/options/validate", True), + ("post", "/addons/local_ssh/rebuild", True), + ("post", "/addons/local_ssh/stdin", True), + ("post", "/addons/local_ssh/security", True), + ("get", "/addons/local_ssh/stats", True), + ("get", "/addons/local_ssh/logs", False), + ("get", "/addons/local_ssh/logs/follow", False), + ("get", "/addons/local_ssh/logs/boots/1", False), + ("get", "/addons/local_ssh/logs/boots/1/follow", False), + ], +) +@pytest.mark.usefixtures("repository") +async def test_addon_not_installed( + api_client: TestClient, method: str, url: str, json_expected: bool +): + """Test addon not installed error.""" + resp = await api_client.request(method, url) + assert resp.status == 400 + assert await get_message(resp, json_expected) == "Addon is not installed" diff --git a/tests/api/test_discovery.py b/tests/api/test_discovery.py index 01295859b24..32e83ff83bc 100644 --- a/tests/api/test_discovery.py +++ b/tests/api/test_discovery.py @@ -138,3 +138,15 @@ async def test_api_invalid_discovery(api_client: TestClient, install_addon_ssh: resp = await api_client.post("/discovery", json={"service": "test", "config": None}) assert resp.status == 400 + + +@pytest.mark.parametrize( + ("method", "url"), + [("get", "/discovery/bad"), ("delete", "/discovery/bad")], +) +async def test_discovery_not_found(api_client: TestClient, method: str, url: str): + """Test discovery not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + resp = await resp.json() + assert resp["message"] == "Discovery message not found" diff --git a/tests/api/test_docker.py b/tests/api/test_docker.py index 040a958d46e..1cf6ec1c99a 100644 --- a/tests/api/test_docker.py +++ b/tests/api/test_docker.py @@ -1,10 +1,11 @@ """Test Docker API.""" +from aiohttp.test_utils import TestClient import pytest @pytest.mark.asyncio -async def test_api_docker_info(api_client): +async def test_api_docker_info(api_client: TestClient): """Test docker info api.""" resp = await api_client.get("/docker/info") result = await resp.json() @@ -12,3 +13,11 @@ async def test_api_docker_info(api_client): assert result["data"]["logging"] == "journald" assert result["data"]["storage"] == "overlay2" assert result["data"]["version"] == "1.0.0" + + +async def test_registry_not_found(api_client: TestClient): + """Test registry not found error.""" + resp = await api_client.delete("/docker/registries/bad") + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "Hostname bad does not exist in registries" diff --git a/tests/api/test_jobs.py b/tests/api/test_jobs.py index 562405af48c..00e798e227b 100644 --- a/tests/api/test_jobs.py +++ b/tests/api/test_jobs.py @@ -4,6 +4,7 @@ from unittest.mock import ANY from aiohttp.test_utils import TestClient +import pytest from supervisor.coresys import CoreSys from supervisor.jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition @@ -213,6 +214,18 @@ async def test_job_manual_cleanup(self) -> None: # Confirm it no longer exists resp = await api_client.get(f"/jobs/{test.job_id}") - assert resp.status == 400 + assert resp.status == 404 result = await resp.json() - assert result["message"] == f"No job found with id {test.job_id}" + assert result["message"] == "Job does not exist" + + +@pytest.mark.parametrize( + ("method", "url"), + [("get", "/jobs/bad"), ("delete", "/jobs/bad")], +) +async def test_job_not_found(api_client: TestClient, method: str, url: str): + """Test job not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "Job does not exist" diff --git a/tests/api/test_mounts.py b/tests/api/test_mounts.py index 367c7c33d90..e22dcbb8763 100644 --- a/tests/api/test_mounts.py +++ b/tests/api/test_mounts.py @@ -264,25 +264,6 @@ async def test_api_update_mount( coresys.mounts.save_data.assert_called_once() -async def test_api_update_error_mount_missing( - api_client: TestClient, mount_propagation -): - """Test update mount API errors when mount does not exist.""" - resp = await api_client.put( - "/mounts/backup_test", - json={ - "type": "cifs", - "usage": "backup", - "server": "backup.local", - "share": "new_backups", - }, - ) - assert resp.status == 400 - result = await resp.json() - assert result["result"] == "error" - assert result["message"] == "No mount exists with name backup_test" - - async def test_api_update_dbus_error_mount_remains( api_client: TestClient, all_dbus_services: dict[str, DBusServiceMock], @@ -399,20 +380,6 @@ async def test_api_reload_mount( ] -async def test_api_reload_error_mount_missing( - api_client: TestClient, mount_propagation -): - """Test reload mount API errors when mount does not exist.""" - resp = await api_client.post("/mounts/backup_test/reload") - assert resp.status == 400 - result = await resp.json() - assert result["result"] == "error" - assert ( - result["message"] - == "Cannot reload 'backup_test', no mount exists with that name" - ) - - async def test_api_delete_mount( api_client: TestClient, coresys: CoreSys, @@ -435,20 +402,6 @@ async def test_api_delete_mount( coresys.mounts.save_data.assert_called_once() -async def test_api_delete_error_mount_missing( - api_client: TestClient, mount_propagation -): - """Test delete mount API errors when mount does not exist.""" - resp = await api_client.delete("/mounts/backup_test") - assert resp.status == 400 - result = await resp.json() - assert result["result"] == "error" - assert ( - result["message"] - == "Cannot remove 'backup_test', no mount exists with that name" - ) - - async def test_api_create_backup_mount_sets_default( api_client: TestClient, coresys: CoreSys, @@ -903,3 +856,19 @@ async def test_api_read_only_backup_mount_invalid( result = await resp.json() assert result["result"] == "error" assert "Backup mounts cannot be read only" in result["message"] + + +@pytest.mark.parametrize( + ("method", "url"), + [ + ("put", "/mounts/bad"), + ("delete", "/mounts/bad"), + ("post", "/mounts/bad/reload"), + ], +) +async def test_mount_not_found(api_client: TestClient, method: str, url: str): + """Test mount not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + resp = await resp.json() + assert resp["message"] == "No mount exists with name bad" diff --git a/tests/api/test_network.py b/tests/api/test_network.py index 309dccc7172..e609df410b4 100644 --- a/tests/api/test_network.py +++ b/tests/api/test_network.py @@ -400,3 +400,22 @@ async def test_api_network_vlan( "id": Variant("u", 1), "parent": Variant("s", "0c23631e-2118-355c-bbb0-8943229cb0d6"), } + + +@pytest.mark.parametrize( + ("method", "url"), + [ + ("get", "/network/interface/bad/info"), + ("post", "/network/interface/bad/update"), + ("get", "/network/interface/bad/accesspoints"), + ("post", "/network/interface/bad/vlan/1"), + ], +) +async def test_network_interface_not_found( + api_client: TestClient, method: str, url: str +): + """Test network interface not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "Interface bad does not exist" diff --git a/tests/api/test_resolution.py b/tests/api/test_resolution.py index f3db99e2f4d..b3fc68af67a 100644 --- a/tests/api/test_resolution.py +++ b/tests/api/test_resolution.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from aiohttp.test_utils import TestClient import pytest from supervisor.const import ( @@ -24,7 +25,7 @@ @pytest.mark.asyncio -async def test_api_resolution_base(coresys: CoreSys, api_client): +async def test_api_resolution_base(coresys: CoreSys, api_client: TestClient): """Test resolution manager api.""" coresys.resolution.unsupported = UnsupportedReason.OS coresys.resolution.suggestions = Suggestion( @@ -42,7 +43,9 @@ async def test_api_resolution_base(coresys: CoreSys, api_client): @pytest.mark.asyncio -async def test_api_resolution_dismiss_suggestion(coresys: CoreSys, api_client): +async def test_api_resolution_dismiss_suggestion( + coresys: CoreSys, api_client: TestClient +): """Test resolution manager suggestion apply api.""" coresys.resolution.suggestions = clear_backup = Suggestion( SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM @@ -54,7 +57,9 @@ async def test_api_resolution_dismiss_suggestion(coresys: CoreSys, api_client): @pytest.mark.asyncio -async def test_api_resolution_apply_suggestion(coresys: CoreSys, api_client): +async def test_api_resolution_apply_suggestion( + coresys: CoreSys, api_client: TestClient +): """Test resolution manager suggestion apply api.""" coresys.resolution.suggestions = clear_backup = Suggestion( SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM @@ -82,7 +87,7 @@ async def test_api_resolution_apply_suggestion(coresys: CoreSys, api_client): @pytest.mark.asyncio -async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client): +async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client: TestClient): """Test resolution manager issue apply api.""" coresys.resolution.issues = updated_failed = Issue( IssueType.UPDATE_FAILED, ContextType.SYSTEM @@ -94,7 +99,7 @@ async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client): @pytest.mark.asyncio -async def test_api_resolution_unhealthy(coresys: CoreSys, api_client): +async def test_api_resolution_unhealthy(coresys: CoreSys, api_client: TestClient): """Test resolution manager api.""" coresys.resolution.unhealthy = UnhealthyReason.DOCKER @@ -104,7 +109,7 @@ async def test_api_resolution_unhealthy(coresys: CoreSys, api_client): @pytest.mark.asyncio -async def test_api_resolution_check_options(coresys: CoreSys, api_client): +async def test_api_resolution_check_options(coresys: CoreSys, api_client: TestClient): """Test client API with checks options.""" free_space = coresys.resolution.check.get("free_space") @@ -121,7 +126,7 @@ async def test_api_resolution_check_options(coresys: CoreSys, api_client): @pytest.mark.asyncio -async def test_api_resolution_check_run(coresys: CoreSys, api_client): +async def test_api_resolution_check_run(coresys: CoreSys, api_client: TestClient): """Test client API with run check.""" coresys.core.state = CoreState.RUNNING free_space = coresys.resolution.check.get("free_space") @@ -133,7 +138,9 @@ async def test_api_resolution_check_run(coresys: CoreSys, api_client): assert free_space.run_check.called -async def test_api_resolution_suggestions_for_issue(coresys: CoreSys, api_client): +async def test_api_resolution_suggestions_for_issue( + coresys: CoreSys, api_client: TestClient +): """Test getting suggestions that fix an issue.""" coresys.resolution.issues = corrupt_repo = Issue( IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "repo_1" @@ -165,3 +172,39 @@ async def test_api_resolution_suggestions_for_issue(coresys: CoreSys, api_client ] assert len(suggestion) == 1 assert suggestion[0]["auto"] is False + + +@pytest.mark.parametrize( + ("method", "url"), + [("delete", "/resolution/issue/bad"), ("get", "/resolution/issue/bad/suggestions")], +) +async def test_issue_not_found(api_client: TestClient, method: str, url: str): + """Test issue not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "The supplied UUID is not a valid issue" + + +@pytest.mark.parametrize( + ("method", "url"), + [("delete", "/resolution/suggestion/bad"), ("post", "/resolution/suggestion/bad")], +) +async def test_suggestion_not_found(api_client: TestClient, method: str, url: str): + """Test suggestion not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "The supplied UUID is not a valid suggestion" + + +@pytest.mark.parametrize( + ("method", "url"), + [("post", "/resolution/check/bad/options"), ("post", "/resolution/check/bad/run")], +) +async def test_check_not_found(api_client: TestClient, method: str, url: str): + """Test check not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "The supplied check slug is not available" diff --git a/tests/api/test_services.py b/tests/api/test_services.py new file mode 100644 index 00000000000..d2652afc6fe --- /dev/null +++ b/tests/api/test_services.py @@ -0,0 +1,16 @@ +"""Test services API.""" + +from aiohttp.test_utils import TestClient +import pytest + + +@pytest.mark.parametrize( + ("method", "url"), + [("get", "/services/bad"), ("post", "/services/bad"), ("delete", "/services/bad")], +) +async def test_service_not_found(api_client: TestClient, method: str, url: str): + """Test service not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "Service does not exist" diff --git a/tests/api/test_store.py b/tests/api/test_store.py index f75d7237393..0a11df9b02a 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import MagicMock, PropertyMock, patch +from aiohttp import ClientResponse from aiohttp.test_utils import TestClient import pytest @@ -235,7 +236,7 @@ async def test_api_detached_addon_changelog( resp = await api_client.get(f"/{resource}/{install_addon_ssh.slug}/changelog") assert resp.status == 200 result = await resp.text() - assert result == "Addon local_ssh with version latest does not exist in the store" + assert result == "Addon local_ssh does not exist in the store" @pytest.mark.parametrize("resource", ["store/addons", "addons"]) @@ -279,4 +280,72 @@ async def test_api_detached_addon_documentation( resp = await api_client.get(f"/{resource}/{install_addon_ssh.slug}/documentation") assert resp.status == 200 result = await resp.text() - assert result == "Addon local_ssh with version latest does not exist in the store" + assert result == "Addon local_ssh does not exist in the store" + + +async def get_message(resp: ClientResponse, json_expected: bool) -> str: + """Get message from response based on response type.""" + if json_expected: + body = await resp.json() + return body["message"] + return await resp.text() + + +@pytest.mark.parametrize( + ("method", "url", "json_expected"), + [ + ("get", "/store/addons/bad", True), + ("get", "/store/addons/bad/1", True), + ("get", "/store/addons/bad/icon", False), + ("get", "/store/addons/bad/logo", False), + ("post", "/store/addons/bad/install", True), + ("post", "/store/addons/bad/install/1", True), + ("post", "/store/addons/bad/update", True), + ("post", "/store/addons/bad/update/1", True), + # Legacy paths + ("get", "/addons/bad/icon", False), + ("get", "/addons/bad/logo", False), + ("post", "/addons/bad/install", True), + ("post", "/addons/bad/update", True), + ], +) +async def test_store_addon_not_found( + api_client: TestClient, method: str, url: str, json_expected: bool +): + """Test store addon not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + assert await get_message(resp, json_expected) == "Addon bad does not exist" + + +@pytest.mark.parametrize( + ("method", "url"), + [ + ("post", "/store/addons/local_ssh/update"), + ("post", "/store/addons/local_ssh/update/1"), + # Legacy paths + ("post", "/addons/local_ssh/update"), + ], +) +@pytest.mark.usefixtures("repository") +async def test_store_addon_not_installed(api_client: TestClient, method: str, url: str): + """Test store addon not installed error.""" + resp = await api_client.request(method, url) + assert resp.status == 400 + body = await resp.json() + assert body["message"] == "Addon local_ssh is not installed" + + +@pytest.mark.parametrize( + ("method", "url"), + [ + ("get", "/store/repositories/bad"), + ("delete", "/store/repositories/bad"), + ], +) +async def test_repository_not_found(api_client: TestClient, method: str, url: str): + """Test repository not found error.""" + resp = await api_client.request(method, url) + assert resp.status == 404 + body = await resp.json() + assert body["message"] == "Repository bad does not exist in the store"