diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index cca5b8f34fd..72cb973fa26 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -23,6 +23,7 @@ from homeassistant.loader import async_get_integration from .base import HacsBase +from .brands import async_register_icon_view from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION from .data_client import HacsDataClient from .enums import HacsDisabledReason, HacsStage, LovelaceMode @@ -143,6 +144,7 @@ async def async_startup(): async_register_websocket_commands(hass) await async_register_frontend(hass, hacs) + async_register_icon_view(hass) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/custom_components/hacs/brands.py b/custom_components/hacs/brands.py new file mode 100644 index 00000000000..5db85655435 --- /dev/null +++ b/custom_components/hacs/brands.py @@ -0,0 +1,279 @@ +"""Serve brand icons for repositories managed by HACS. + +Custom integrations ship their brand images in a local brand folder +(custom_components//brand/) since Home Assistant 2026.3, and +home-assistant/brands no longer accepts images for custom integrations. + +This view serves those icons to the HACS frontend: +- Downloaded integrations are served straight from the local brand folder. +- Integrations that are not downloaded are fetched from the repository + content on GitHub and cached on disk. The cache is keyed by the version + the icon was fetched for, so a new release invalidates it automatically. +- Repositories without a brand icon are redirected to the brands CDN, + which still hosts previously accepted images. +""" + +from __future__ import annotations + +import asyncio +from http import HTTPStatus +from pathlib import Path +import re +import time +from typing import TYPE_CHECKING +from urllib.parse import quote + +from aiohttp import ClientError, ClientTimeout, hdrs, web + +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .enums import HacsCategory + +if TYPE_CHECKING: + from .base import HacsBase + from .repositories.base import HacsRepository + +URL_BASE = "/api/hacs/repository" +BRANDS_CDN_URL = "https://brands.home-assistant.io/_" +CACHE_DIR = ".storage/hacs.icons" +CACHE_CONTROL = f"public, max-age={60 * 60 * 24}" +NO_CACHE = "no-store" +NEGATIVE_CACHE_TTL = 60 * 60 * 24 * 7 +MAX_ICON_SIZE = 1024 * 1024 +DOWNLOAD_CHUNK_SIZE = 64 * 1024 +DEFAULT_REF = "main" +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +VALID_FILENAMES = ("icon.png", "dark_icon.png") +BRANDS_DOMAIN = "brands" + +_DOMAIN_RE = re.compile(r"[a-z0-9_]+") + +_VIEW_REGISTERED = "hacs_repository_icon_view" + + +def _read_file(path: Path) -> bytes | None: + """Read a file, returning None if it can not be read.""" + try: + return path.read_bytes() + except OSError: + return None + + +def _validate_icon(content: bytes | None) -> bytes | None: + """Return the content if it is a PNG image within the size limit.""" + if content is None or len(content) > MAX_ICON_SIZE or not content.startswith(PNG_MAGIC): + return None + return content + + +def _cache_lookup(cache_file: Path, marker_file: Path) -> tuple[bytes | None, bool]: + """Return cached icon content and whether a fresh negative marker exists.""" + if (content := _validate_icon(_read_file(cache_file))) is not None: + return content, False + try: + fresh = (time.time() - marker_file.stat().st_mtime) < NEGATIVE_CACHE_TTL + except OSError: + fresh = False + return None, fresh + + +def _cache_write( + cache_dir: Path, + prefix: str, + filename: str, + target: Path, + content: bytes | None, +) -> None: + """Write icon content (or a negative marker) and drop entries for old versions.""" + cache_dir.mkdir(parents=True, exist_ok=True) + for stale in ( + *cache_dir.glob(f"{prefix}*-{filename}"), + *cache_dir.glob(f"{prefix}*-{filename}.missing"), + ): + if stale != target: + stale.unlink(missing_ok=True) + if content is None: + target.touch() + else: + target.write_bytes(content) + + +class HacsRepositoryIconView(HomeAssistantView): + """Serve brand icons for HACS repositories.""" + + url = f"{URL_BASE}/{{repository_id}}/{{filename}}" + name = "api:hacs:repository:icon" + requires_auth = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the view.""" + self._hass = hass + self._cache_dir = Path(hass.config.path(CACHE_DIR)) + self._locks: dict[str, asyncio.Lock] = {} + + def _authenticate(self, request: web.Request) -> None: + """Authenticate with Home Assistant or a brands access token.""" + access_tokens = self._hass.data.get(BRANDS_DOMAIN, ()) + authenticated = request.get(KEY_AUTHENTICATED, False) or ( + request.query.get("token") in access_tokens + ) + if authenticated: + return + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + raise web.HTTPForbidden + + async def get( + self, + request: web.Request, + repository_id: str, + filename: str, + ) -> web.StreamResponse: + """Serve the brand icon for a repository.""" + self._authenticate(request) + if (hacs := self._hass.data.get(DOMAIN)) is None: + raise web.HTTPServiceUnavailable + + repository = hacs.repositories.get_by_id(repository_id) + if ( + filename not in VALID_FILENAMES + or repository is None + or repository.data.category != HacsCategory.INTEGRATION + or not repository.data.domain + or not _DOMAIN_RE.fullmatch(repository.data.domain) + ): + raise web.HTTPNotFound + + token = request.query.get("token") + if repository.data.installed: + return await self._async_serve_local(repository, filename, token) + return await self._async_serve_remote(hacs, repository, filename, token) + + async def _async_serve_local( + self, repository: HacsRepository, filename: str, token: str | None + ) -> web.StreamResponse: + """Serve the icon from the downloaded integration.""" + brand_path = Path( + self._hass.config.path("custom_components", repository.data.domain, "brand", filename) + ) + content = _validate_icon(await self._hass.async_add_executor_job(_read_file, brand_path)) + if content is not None: + return self._icon_response(content) + return self._fallback_response(repository, filename, token=token) + + async def _async_serve_remote( + self, + hacs: HacsBase, + repository: HacsRepository, + filename: str, + token: str | None, + ) -> web.StreamResponse: + """Serve the icon from the GitHub repository content, with caching.""" + ref = repository.data.last_version or repository.data.default_branch or DEFAULT_REF + cache_ref = repository.data.last_version or repository.data.last_commit or ref + + prefix = f"{repository.data.id}-" + cache_file = self._cache_dir / f"{prefix}{quote(cache_ref, safe='')}-{filename}" + marker_file = cache_file.parent / f"{cache_file.name}.missing" + retry = False + + lock = self._locks.setdefault(str(repository.data.id), asyncio.Lock()) + async with lock: + content, missing = await self._hass.async_add_executor_job( + _cache_lookup, cache_file, marker_file + ) + if content is None and not missing: + content, cache_missing = await self._async_download_icon( + hacs, repository, ref, filename + ) + if content is not None or cache_missing: + await self._hass.async_add_executor_job( + _cache_write, + self._cache_dir, + prefix, + filename, + cache_file if content is not None else marker_file, + content, + ) + else: + retry = True + + if content is not None: + return self._icon_response(content) + return self._fallback_response(repository, filename, cache=not retry, token=token) + + async def _async_download_icon( + self, hacs: HacsBase, repository: HacsRepository, ref: str, filename: str + ) -> tuple[bytes | None, bool]: + """Download the icon and report whether a missing result can be cached.""" + brand_path = ( + "brand" + if repository.repository_manifest.content_in_root + else f"custom_components/{repository.data.domain}/brand" + ) + url = ( + f"https://raw.githubusercontent.com/{repository.data.full_name}/" + f"{quote(ref, safe='/')}/{brand_path}/{filename}" + ) + try: + response = await hacs.session.get(url, timeout=ClientTimeout(total=60)) + except (ClientError, TimeoutError): + return None, False + + try: + if response.status == HTTPStatus.NOT_FOUND: + return None, True + if response.status != HTTPStatus.OK: + return None, False + + content = bytearray() + async for chunk in response.content.iter_chunked(DOWNLOAD_CHUNK_SIZE): + content.extend(chunk) + if len(content) > MAX_ICON_SIZE: + return None, True + validated = _validate_icon(bytes(content)) + return validated, validated is None + except (ClientError, TimeoutError): + return None, False + finally: + response.release() + + def _icon_response(self, content: bytes) -> web.Response: + """Return the icon content.""" + return web.Response( + body=content, + content_type="image/png", + headers={"Cache-Control": CACHE_CONTROL}, + ) + + def _fallback_response( + self, + repository: HacsRepository, + filename: str, + *, + cache: bool = True, + token: str | None = None, + ) -> web.StreamResponse: + """Redirect to the icon variant, or to the brands CDN.""" + if filename != "icon.png": + location = f"{URL_BASE}/{repository.data.id}/icon.png" + if token is not None: + location = f"{location}?token={quote(token, safe='')}" + else: + location = f"{BRANDS_CDN_URL}/{repository.data.domain}/{filename}" + return web.HTTPFound( + location, + headers={"Cache-Control": CACHE_CONTROL if cache else NO_CACHE}, + ) + + +@callback +def async_register_icon_view(hass: HomeAssistant) -> None: + """Register the repository icon view.""" + if hass.data.get(_VIEW_REGISTERED): + return + view = HacsRepositoryIconView(hass) + hass.data[_VIEW_REGISTERED] = view + hass.http.register_view(view) diff --git a/tests/common.py b/tests/common.py index f7f193374c8..0d47b5eb3a1 100644 --- a/tests/common.py +++ b/tests/common.py @@ -402,6 +402,10 @@ def url(self): def headers(self): return self.kwargs.get("headers", {}) + @property + def content(self): + return MockedResponseContent(self) + async def read(self, **kwargs): if (content := self.kwargs.get("content")) is not None: return content @@ -421,6 +425,19 @@ def raise_for_status(self) -> None: if self.status >= 300: raise ClientError(self.status) + def release(self) -> None: + """Release the mocked response.""" + + +class MockedResponseContent: + def __init__(self, response: MockedResponse) -> None: + self.response = response + + async def iter_chunked(self, size: int): + content = await self.response.read() + for offset in range(0, len(content), size): + yield content[offset : offset + size] + class ResponseMocker: calls: list[dict[str, Any]] = [] diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json new file mode 100644 index 00000000000..665f0bfb647 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_downloaded_icon_invalid_content": { + "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_brandstest-icon-view-downloaded-icon-missing.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-missing.json new file mode 100644 index 00000000000..d5ad7c2d8fc --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-missing.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_downloaded_icon_missing": { + "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_brandstest-icon-view-downloaded-icon.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon.json new file mode 100644 index 00000000000..98350236626 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_downloaded_icon": { + "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_brandstest-icon-view-not-found-0000000-icon-png.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-0000000-icon-png.json new file mode 100644 index 00000000000..68ef78beb7c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-0000000-icon-png.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_not_found[0000000-icon.png]": { + "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_brandstest-icon-view-not-found-1296269-icon-png.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-icon-png.json new file mode 100644 index 00000000000..ad07f1dfa08 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-icon-png.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_not_found[1296269-../icon.png]": { + "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_brandstest-icon-view-not-found-1296269-not-an-icon-png.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-not-an-icon-png.json new file mode 100644 index 00000000000..b38a5e4bf24 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-not-an-icon-png.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_not_found[1296269-not_an_icon.png]": { + "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_brandstest-icon-view-remote-branch-cache-uses-commit.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-branch-cache-uses-commit.json new file mode 100644 index 00000000000..681ea154220 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-branch-cache-uses-commit.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_branch_cache_uses_commit": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/brand/icon.png": 2 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json new file mode 100644 index 00000000000..d42b0f7acd7 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_content_in_root": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json new file mode 100644 index 00000000000..5cc1633aaea --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_dark_icon_fallback": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/dark_icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json new file mode 100644 index 00000000000..8b08054a57c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_error_is_not_cached": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 2 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json new file mode 100644 index 00000000000..72fe4a7f0b3 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon_invalid_content": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json new file mode 100644 index 00000000000..0405eac62a1 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon_missing": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json new file mode 100644 index 00000000000..3e4eae2799c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon_size_limit": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json new file mode 100644 index 00000000000..6f36df4e67e --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json new file mode 100644 index 00000000000..7fd1b693f68 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_without_any_stored_ref": { + "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, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/test_brands.py b/tests/test_brands.py new file mode 100644 index 00000000000..a6801f6b06f --- /dev/null +++ b/tests/test_brands.py @@ -0,0 +1,346 @@ +"""Test the repository brand icon view.""" + +from collections.abc import Generator +import os +from unittest.mock import MagicMock + +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from homeassistant.core import HomeAssistant +import pytest + +from custom_components.hacs.brands import MAX_ICON_SIZE, PNG_MAGIC, HacsRepositoryIconView +from custom_components.hacs.const import DOMAIN + +from tests.common import MockedResponse, ResponseMocker, get_hacs + +REPOSITORY_ID = "1296269" +REPOSITORY_FULL_NAME = "hacs-test-org/integration-basic" +ICON_CONTENT = PNG_MAGIC + b"icon-content" +DARK_ICON_CONTENT = PNG_MAGIC + b"dark-icon-content" +ACCESS_TOKEN = "test-token" +RAW_ICON_URL = ( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/1.0.0/custom_components/example/brand/icon.png" +) +RAW_DARK_ICON_URL = ( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/1.0.0/custom_components/example/brand/dark_icon.png" +) + + +async def _get_icon( + hass: HomeAssistant, + repository_id: str, + filename: str, + *, + authenticated: bool = True, + view: HacsRepositoryIconView | None = None, +) -> web.StreamResponse: + view = view or HacsRepositoryIconView(hass) + url = f"/api/hacs/repository/{repository_id}/{filename}" + if authenticated: + hass.data["brands"] = (ACCESS_TOKEN,) + url = f"{url}?token={ACCESS_TOKEN}" + request = make_mocked_request("GET", url) + return await view.get(request, repository_id=repository_id, filename=filename) + + +async def test_icon_view_requires_authentication( + hass: HomeAssistant, +) -> None: + """Test that unauthenticated icon requests are rejected.""" + with pytest.raises(web.HTTPForbidden): + await _get_icon(hass, REPOSITORY_ID, "icon.png", authenticated=False) + + +@pytest.mark.parametrize( + ("repository_id", "filename"), + [ + (REPOSITORY_ID, "not_an_icon.png"), + (REPOSITORY_ID, "../icon.png"), + ("0000000", "icon.png"), + ], +) +async def test_icon_view_not_found( + hass: HomeAssistant, + setup_integration: Generator, + repository_id: str, + filename: str, +) -> None: + """Test that unknown repositories and unexpected filenames return 404.""" + with pytest.raises(web.HTTPNotFound): + await _get_icon(hass, repository_id, filename) + + +async def test_icon_view_remote_icon( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test serving the icon of a repository that is not downloaded.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(content=ICON_CONTENT)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + assert response.content_type == "image/png" + assert "max-age" in response.headers["Cache-Control"] + + cache_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png") + assert os.path.exists(cache_file) + + # A second request is served from the cache without hitting GitHub again, + # the mocked response was consumed by the first request. + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + assert response.status == 200 + assert response.body == ICON_CONTENT + + +async def test_icon_view_remote_icon_missing( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test redirecting to the brands CDN when the repository has no icon.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(status=404)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" + + marker_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png.missing") + assert os.path.exists(marker_file) + + # The negative result is cached, GitHub is not asked again. + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + assert response.status == 302 + + +async def test_icon_view_remote_icon_invalid_content( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test that content that is not a PNG image is not served.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(content=b"not a png")) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" + + +async def test_icon_view_remote_dark_icon_fallback( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test redirecting to the regular icon when there is no dark variant.""" + response_mocker.add(RAW_DARK_ICON_URL, MockedResponse(status=404)) + + response = await _get_icon(hass, REPOSITORY_ID, "dark_icon.png") + + assert response.status == 302 + assert response.headers["Location"] == ( + f"/api/hacs/repository/{REPOSITORY_ID}/icon.png?token={ACCESS_TOKEN}" + ) + + +async def test_icon_view_remote_error_is_not_cached( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test that transient download errors are retried on the next request.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(status=500)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Cache-Control"] == "no-store" + marker_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png.missing") + assert not os.path.exists(marker_file) + + response_mocker.add(RAW_ICON_URL, MockedResponse(content=ICON_CONTENT)) + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + + +async def test_icon_view_remote_icon_size_limit( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test that oversized remote icons are rejected while streaming.""" + response_mocker.add( + RAW_ICON_URL, + MockedResponse(content=PNG_MAGIC + bytes(MAX_ICON_SIZE)), + ) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + marker_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png.missing") + assert os.path.exists(marker_file) + + +async def test_icon_view_remote_content_in_root( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test loading an icon from a repository with content in its root.""" + repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.repository_manifest.content_in_root = True + response_mocker.add( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/brand/icon.png", + MockedResponse(content=ICON_CONTENT), + ) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + + +async def test_icon_view_remote_branch_cache_uses_commit( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test refreshing cached branch icons when the latest commit changes.""" + repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.last_version = None + repository.data.default_branch = "main" + repository.data.last_commit = "abc1234" + url = ( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/main/custom_components/example/brand/icon.png" + ) + response_mocker.add(url, MockedResponse(content=ICON_CONTENT)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert os.path.exists(hass.config.path(".storage/hacs.icons/1296269-abc1234-icon.png")) + + repository.data.last_commit = "def5678" + response_mocker.add(url, MockedResponse(content=DARK_ICON_CONTENT)) + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == DARK_ICON_CONTENT + + +async def test_icon_view_remote_without_any_stored_ref( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test loading an icon before repository refs have been hydrated.""" + repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.last_version = None + repository.data.default_branch = None + repository.data.last_commit = None + response_mocker.add( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/main/custom_components/example/brand/icon.png", + MockedResponse(content=ICON_CONTENT), + ) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + assert os.path.exists(hass.config.path(".storage/hacs.icons/1296269-main-icon.png")) + + +async def test_icon_view_uses_current_hacs_instance( + hass: HomeAssistant, +) -> None: + """Test that a registered view follows the HACS instance across reloads.""" + view = HacsRepositoryIconView(hass) + replacement_hacs = MagicMock() + replacement_hacs.repositories.get_by_id.return_value = None + hass.data[DOMAIN] = replacement_hacs + + with pytest.raises(web.HTTPNotFound): + await _get_icon(hass, REPOSITORY_ID, "icon.png", view=view) + + replacement_hacs.repositories.get_by_id.assert_called_once_with(REPOSITORY_ID) + + +async def test_icon_view_downloaded_icon( + hass: HomeAssistant, + setup_integration: Generator, +) -> None: + """Test serving the local icon of a downloaded repository.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.installed = True + + brand_dir = hass.config.path("custom_components/example/brand") + + def _write_icon() -> None: + os.makedirs(brand_dir, exist_ok=True) + with open(os.path.join(brand_dir, "icon.png"), mode="wb") as icon_file: + icon_file.write(ICON_CONTENT) + + await hass.async_add_executor_job(_write_icon) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + + # The dark variant does not exist, redirect to the regular icon. + response = await _get_icon(hass, REPOSITORY_ID, "dark_icon.png") + + assert response.status == 302 + assert response.headers["Location"] == ( + f"/api/hacs/repository/{REPOSITORY_ID}/icon.png?token={ACCESS_TOKEN}" + ) + + +async def test_icon_view_downloaded_icon_invalid_content( + hass: HomeAssistant, + setup_integration: Generator, +) -> None: + """Test that a local file that is not a PNG image is not served.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.installed = True + + brand_dir = hass.config.path("custom_components/example/brand") + + def _write_icon() -> None: + os.makedirs(brand_dir, exist_ok=True) + with open(os.path.join(brand_dir, "icon.png"), mode="wb") as icon_file: + icon_file.write(b"not a png") + + await hass.async_add_executor_job(_write_icon) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" + + +async def test_icon_view_downloaded_icon_missing( + hass: HomeAssistant, + setup_integration: Generator, +) -> None: + """Test redirecting to the brands CDN when the download has no brand folder.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.installed = True + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png"