diff --git a/custom_components/hacs/websocket/helpers.py b/custom_components/hacs/websocket/helpers.py new file mode 100644 index 00000000000..9baab4d1d22 --- /dev/null +++ b/custom_components/hacs/websocket/helpers.py @@ -0,0 +1,32 @@ +"""Helpers for the HACS websocket API.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from homeassistant.components import websocket_api + + from ..base import HacsBase + from ..repositories.base import HacsRepository + + +def resolve_repository( + hacs: HacsBase, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + repository_id: str, +) -> HacsRepository | None: + """Resolve a repository by id, send an error when it is unknown. + + A stale frontend can reference repositories that no longer exist, + the caller should return early when this returns None. + """ + if (repository := hacs.repositories.get_by_id(repository_id)) is None: + connection.send_error( + msg["id"], + "repository_not_found", + f"Repository with ID ({repository_id}) not found", + ) + + return repository diff --git a/custom_components/hacs/websocket/repositories.py b/custom_components/hacs/websocket/repositories.py index 879f68af8da..3843166157d 100644 --- a/custom_components/hacs/websocket/repositories.py +++ b/custom_components/hacs/websocket/repositories.py @@ -13,6 +13,7 @@ from ..const import DOMAIN from ..enums import HacsDispatchEvent +from .helpers import resolve_repository if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -94,7 +95,8 @@ async def hacs_repositories_clear_new( hacs: HacsBase = hass.data.get(DOMAIN) if repo := msg.get("repository"): - repository = hacs.repositories.get_by_id(repo) + if (repository := resolve_repository(hacs, connection, msg, repo)) is None: + return repository.data.new = False else: @@ -208,7 +210,8 @@ async def hacs_repositories_remove( ) -> None: """Remove custom repositoriy.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return repository.remove() await hacs.data.async_write() diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 70752383242..fc498d97f9b 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -12,6 +12,7 @@ from ..enums import HacsDispatchEvent from ..exceptions import HacsException from ..utils.version import version_left_higher_then_right +from .helpers import resolve_repository if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -34,14 +35,7 @@ async def hacs_repository_info( ) -> None: """Return information about a repository.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository_id = msg["repository_id"] - repository = hacs.repositories.get_by_id(repository_id) - if repository is None: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID ({repository_id}) not found", - ) + if (repository := resolve_repository(hacs, connection, msg, msg["repository_id"])) is None: return if not repository.updated_info: @@ -113,15 +107,8 @@ async def hacs_repository_ignore( ) -> None: """Ignore a repository.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository_id = msg["repository"] - hacs.log.info("Ignoring %s", repository_id) - repository = hacs.repositories.get_by_id(repository_id) - if repository is None: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID ({repository_id}) not found", - ) + hacs.log.info("Ignoring %s", msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: return hacs.common.ignored_repositories.add(repository.data.full_name) @@ -146,7 +133,8 @@ async def hacs_repository_state( ) -> None: """Set the state of a repository""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return repository.state = msg["state"] @@ -170,7 +158,8 @@ async def hacs_repository_version( ) -> None: """Set the version of a repository""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return if msg["version"] == repository.data.default_branch: repository.data.selected_tag = None @@ -200,7 +189,8 @@ async def hacs_repository_beta( ) -> None: """Show or hide beta versions of a repository""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return repository.data.show_beta = msg["show_beta"] @@ -227,7 +217,8 @@ async def hacs_repository_download( ) -> None: """Set the version of a repository""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return try: was_installed = repository.data.installed @@ -258,7 +249,8 @@ async def hacs_repository_remove( ) -> None: """Remove a repository.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return repository.data.new = False try: @@ -286,7 +278,8 @@ async def hacs_repository_refresh( ) -> None: """Refresh a repository.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return await repository.update_repository(ignore_issues=True, force=True) await hacs.data.async_write() @@ -311,7 +304,8 @@ async def hacs_repository_release_notes( ) -> None: """Return release notes.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository"])) is None: + return connection.send_message( websocket_api.result_message( @@ -345,7 +339,8 @@ async def hacs_repository_releases( ) -> None: """Return releases.""" hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository_id"]) + if (repository := resolve_repository(hacs, connection, msg, msg["repository_id"])) is None: + return try: releases = await repository.async_get_releases() except Exception as exception: diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repositories-clear-new.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repositories-clear-new.json new file mode 100644 index 00000000000..6efe5c976ab --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repositories-clear-new.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repositories-clear_new-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repositories-remove.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repositories-remove.json new file mode 100644 index 00000000000..4ce476d0d9f --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repositories-remove.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repositories-remove-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-beta.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-beta.json new file mode 100644 index 00000000000..8d249d2a83c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-beta.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-beta-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-download.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-download.json new file mode 100644 index 00000000000..c5a38107b5e --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-download.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-download-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-ignore.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-ignore.json new file mode 100644 index 00000000000..e5c97c4f751 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-ignore.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-ignore-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-info.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-info.json new file mode 100644 index 00000000000..6f6bd191ebd --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-info.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-info-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-refresh.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-refresh.json new file mode 100644 index 00000000000..b8ecf5a8c2e --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-refresh.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-refresh-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-release-notes.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-release-notes.json new file mode 100644 index 00000000000..98ff258ad07 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-release-notes.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-release_notes-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-releases.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-releases.json new file mode 100644 index 00000000000..6c9b3ebf0d3 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-releases.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-releases-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-remove.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-remove.json new file mode 100644 index 00000000000..6b62bcf439d --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-remove.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-remove-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-state.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-state.json new file mode 100644 index 00000000000..f4530900193 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-state.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-state-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-version.json b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-version.json new file mode 100644 index 00000000000..fa20965ab47 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_websockettest-unknown-repository-returns-not-found-repository-version.json @@ -0,0 +1,9 @@ +{ + "tests/test_websocket.py::test_unknown_repository_returns_not_found[repository-version-]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 00000000000..fe2b2d2ff21 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,48 @@ +"""Tests for the HACS websocket API.""" + +from collections.abc import Generator + +from homeassistant.core import HomeAssistant +import pytest + +from tests.common import WSClient + +UNKNOWN_REPOSITORY_ID = "1337404" + + +@pytest.mark.parametrize( + ("command", "payload"), + [ + ("hacs/repository/info", {"repository_id": UNKNOWN_REPOSITORY_ID}), + ("hacs/repository/ignore", {"repository": UNKNOWN_REPOSITORY_ID}), + ("hacs/repository/state", {"repository": UNKNOWN_REPOSITORY_ID, "state": "new"}), + ("hacs/repository/version", {"repository": UNKNOWN_REPOSITORY_ID, "version": "1.0.0"}), + ("hacs/repository/beta", {"repository": UNKNOWN_REPOSITORY_ID, "show_beta": True}), + ("hacs/repository/download", {"repository": UNKNOWN_REPOSITORY_ID}), + ("hacs/repository/remove", {"repository": UNKNOWN_REPOSITORY_ID}), + ("hacs/repository/refresh", {"repository": UNKNOWN_REPOSITORY_ID}), + ("hacs/repository/release_notes", {"repository": UNKNOWN_REPOSITORY_ID}), + ("hacs/repository/releases", {"repository_id": UNKNOWN_REPOSITORY_ID}), + ("hacs/repositories/clear_new", {"repository": UNKNOWN_REPOSITORY_ID}), + ("hacs/repositories/remove", {"repository": UNKNOWN_REPOSITORY_ID}), + ], + ids=lambda value: value.replace("hacs/", "").replace("/", "-") + if isinstance(value, str) + else "", +) +async def test_unknown_repository_returns_not_found( + hass: HomeAssistant, + setup_integration: Generator, + ws_client: WSClient, + command: str, + payload: dict, +): + """Ensure all repository commands handle unknown repository ids.""" + response = await ws_client.send_and_receive_json(command, payload) + + assert response["success"] is False + assert response["error"]["code"] == "repository_not_found" + assert ( + response["error"]["message"] + == f"Repository with ID ({UNKNOWN_REPOSITORY_ID}) not found" + )