Improve Supervisor API handling (#89379)

This commit is contained in:
Franck Nijhof 2023-03-08 15:21:11 +01:00
parent 7f8a9697f0
commit b65180d20a
No known key found for this signature in database
GPG key ID: D62583BA8AB11CA3
10 changed files with 620 additions and 212 deletions

View file

@ -36,6 +36,7 @@ X_AUTH_TOKEN = "X-Supervisor-Token"
X_INGRESS_PATH = "X-Ingress-Path" X_INGRESS_PATH = "X-Ingress-Path"
X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_USER_ID = "X-Hass-User-ID"
X_HASS_IS_ADMIN = "X-Hass-Is-Admin" X_HASS_IS_ADMIN = "X-Hass-Is-Admin"
X_HASS_SOURCE = "X-Hass-Source"
WS_TYPE = "type" WS_TYPE = "type"
WS_ID = "id" WS_ID = "id"

View file

@ -17,7 +17,7 @@ from homeassistant.const import SERVER_PORT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from .const import ATTR_DISCOVERY, DOMAIN from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -445,6 +445,8 @@ class HassIO:
payload=None, payload=None,
timeout=10, timeout=10,
return_text=False, return_text=False,
*,
source="core.handler",
): ):
"""Send API command to Hass.io. """Send API command to Hass.io.
@ -458,7 +460,8 @@ class HassIO:
headers={ headers={
aiohttp.hdrs.AUTHORIZATION: ( aiohttp.hdrs.AUTHORIZATION: (
f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
) ),
X_HASS_SOURCE: source,
}, },
timeout=aiohttp.ClientTimeout(total=timeout), timeout=aiohttp.ClientTimeout(total=timeout),
) )

View file

@ -6,6 +6,7 @@ from http import HTTPStatus
import logging import logging
import os import os
import re import re
from urllib.parse import quote, unquote
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
@ -19,13 +20,16 @@ from aiohttp.hdrs import (
TRANSFER_ENCODING, TRANSFER_ENCODING,
) )
from aiohttp.web_exceptions import HTTPBadGateway from aiohttp.web_exceptions import HTTPBadGateway
from multidict import istr
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.http import (
KEY_AUTHENTICATED,
KEY_HASS_USER,
HomeAssistantView,
)
from homeassistant.components.onboarding import async_is_onboarded from homeassistant.components.onboarding import async_is_onboarded
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID from .const import X_HASS_SOURCE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -34,23 +38,53 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 1024
# pylint: disable=implicit-str-concat # pylint: disable=implicit-str-concat
NO_TIMEOUT = re.compile( NO_TIMEOUT = re.compile(
r"^(?:" r"^(?:"
r"|homeassistant/update"
r"|hassos/update"
r"|hassos/update/cli"
r"|supervisor/update"
r"|addons/[^/]+/(?:update|install|rebuild)"
r"|backups/.+/full" r"|backups/.+/full"
r"|backups/.+/partial" r"|backups/.+/partial"
r"|backups/[^/]+/(?:upload|download)" r"|backups/[^/]+/(?:upload|download)"
r")$" r")$"
) )
NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$") # fmt: off
# Onboarding can upload backups and restore it
PATHS_NOT_ONBOARDED = re.compile(
r"^(?:"
r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?"
r"|backups/new/upload"
r")$"
)
NO_AUTH = re.compile(r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|icon)" r")$") # Authenticated users manage backups + download logs
PATHS_ADMIN = re.compile(
r"^(?:"
r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?"
r"|backups/new/upload"
r"|audio/logs"
r"|cli/logs"
r"|core/logs"
r"|dns/logs"
r"|host/logs"
r"|multicast/logs"
r"|observer/logs"
r"|supervisor/logs"
r"|addons/[^/]+/logs"
r")$"
)
NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") # Unauthenticated requests come in for Supervisor panel + add-on images
PATHS_NO_AUTH = re.compile(
r"^(?:"
r"|app/.*"
r"|(store/)?addons/[^/]+/(logo|icon)"
r")$"
)
NO_STORE = re.compile(
r"^(?:"
r"|app/entrypoint.js"
r")$"
)
# pylint: enable=implicit-str-concat # pylint: enable=implicit-str-concat
# fmt: on
class HassIOView(HomeAssistantView): class HassIOView(HomeAssistantView):
@ -65,38 +99,66 @@ class HassIOView(HomeAssistantView):
self._host = host self._host = host
self._websession = websession self._websession = websession
async def _handle( async def _handle(self, request: web.Request, path: str) -> web.StreamResponse:
self, request: web.Request, path: str
) -> web.Response | web.StreamResponse:
"""Route data to Hass.io."""
hass = request.app["hass"]
if _need_auth(hass, path) and not request[KEY_AUTHENTICATED]:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
return await self._command_proxy(path, request)
delete = _handle
get = _handle
post = _handle
async def _command_proxy(
self, path: str, request: web.Request
) -> web.StreamResponse:
"""Return a client request with proxy origin for Hass.io supervisor. """Return a client request with proxy origin for Hass.io supervisor.
This method is a coroutine. Use cases:
- Onboarding allows restoring backups
- Load Supervisor panel and add-on logo unauthenticated
- User upload/restore backups
""" """
headers = _init_header(request) # No bullshit
if path == "backups/new/upload": if path != unquote(path):
# We need to reuse the full content type that includes the boundary return web.Response(status=HTTPStatus.BAD_REQUEST)
headers[
CONTENT_TYPE hass: HomeAssistant = request.app["hass"]
] = request._stored_content_type # pylint: disable=protected-access is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin
authorized = is_admin
if is_admin:
allowed_paths = PATHS_ADMIN
elif not async_is_onboarded(hass):
allowed_paths = PATHS_NOT_ONBOARDED
# During onboarding we need the user to manage backups
authorized = True
else:
# Either unauthenticated or not an admin
allowed_paths = PATHS_NO_AUTH
no_auth_path = PATHS_NO_AUTH.match(path)
headers = {
X_HASS_SOURCE: "core.http",
}
if no_auth_path:
if request.method != "GET":
return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED)
else:
if not allowed_paths.match(path):
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if authorized:
headers[
AUTHORIZATION
] = f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
if request.method == "POST":
headers[CONTENT_TYPE] = request.content_type
# _stored_content_type is only computed once `content_type` is accessed
if path == "backups/new/upload":
# We need to reuse the full content type that includes the boundary
headers[
CONTENT_TYPE
] = request._stored_content_type # pylint: disable=protected-access
try: try:
client = await self._websession.request( client = await self._websession.request(
method=request.method, method=request.method,
url=f"http://{self._host}/{path}", url=f"http://{self._host}/{quote(path)}",
params=request.query, params=request.query,
data=request.content, data=request.content,
headers=headers, headers=headers,
@ -123,20 +185,8 @@ class HassIOView(HomeAssistantView):
raise HTTPBadGateway() raise HTTPBadGateway()
get = _handle
def _init_header(request: web.Request) -> dict[istr, str]: post = _handle
"""Create initial header."""
headers = {
AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}",
CONTENT_TYPE: request.content_type,
}
# Add user data
if request.get("hass_user") is not None:
headers[istr(X_HASS_USER_ID)] = request["hass_user"].id
headers[istr(X_HASS_IS_ADMIN)] = str(int(request["hass_user"].is_admin))
return headers
def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
@ -164,12 +214,3 @@ def _get_timeout(path: str) -> ClientTimeout:
if NO_TIMEOUT.match(path): if NO_TIMEOUT.match(path):
return ClientTimeout(connect=10, total=None) return ClientTimeout(connect=10, total=None)
return ClientTimeout(connect=10, total=300) return ClientTimeout(connect=10, total=300)
def _need_auth(hass: HomeAssistant, path: str) -> bool:
"""Return if a path need authentication."""
if not async_is_onboarded(hass) and NO_AUTH_ONBOARDING.match(path):
return False
if NO_AUTH.match(path):
return False
return True

View file

@ -3,20 +3,22 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable from collections.abc import Iterable
from functools import lru_cache
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
import os from urllib.parse import quote
import aiohttp import aiohttp
from aiohttp import ClientTimeout, hdrs, web from aiohttp import ClientTimeout, hdrs, web
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
from multidict import CIMultiDict from multidict import CIMultiDict
from yarl import URL
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import X_AUTH_TOKEN, X_INGRESS_PATH from .const import X_HASS_SOURCE, X_INGRESS_PATH
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -42,9 +44,19 @@ class HassIOIngress(HomeAssistantView):
self._host = host self._host = host
self._websession = websession self._websession = websession
@lru_cache
def _create_url(self, token: str, path: str) -> str: def _create_url(self, token: str, path: str) -> str:
"""Create URL to service.""" """Create URL to service."""
return f"http://{self._host}/ingress/{token}/{path}" base_path = f"/ingress/{token}/"
url = f"http://{self._host}{base_path}{quote(path)}"
try:
if not URL(url).path.startswith(base_path):
raise HTTPBadRequest()
except ValueError as err:
raise HTTPBadRequest() from err
return url
async def _handle( async def _handle(
self, request: web.Request, token: str, path: str self, request: web.Request, token: str, path: str
@ -185,10 +197,8 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st
continue continue
headers[name] = value headers[name] = value
# Inject token / cleanup later on Supervisor
headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "")
# Ingress information # Ingress information
headers[X_HASS_SOURCE] = "core.ingress"
headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}"
# Set X-Forwarded-For # Set X-Forwarded-For

View file

@ -116,6 +116,7 @@ async def websocket_supervisor_api(
method=msg[ATTR_METHOD], method=msg[ATTR_METHOD],
timeout=msg.get(ATTR_TIMEOUT, 10), timeout=msg.get(ATTR_TIMEOUT, 10),
payload=msg.get(ATTR_DATA, {}), payload=msg.get(ATTR_DATA, {}),
source="core.websocket_api",
) )
if result.get(ATTR_RESULT) == "error": if result.get(ATTR_RESULT) == "error":

View file

@ -1,5 +1,6 @@
"""Fixtures for Hass.io.""" """Fixtures for Hass.io."""
import os import os
import re
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
@ -12,6 +13,16 @@ from homeassistant.setup import async_setup_component
from . import SUPERVISOR_TOKEN from . import SUPERVISOR_TOKEN
@pytest.fixture(autouse=True)
def disable_security_filter():
"""Disable the security filter to ensure the integration is secure."""
with patch(
"homeassistant.components.http.security_filter.FILTERS",
re.compile("not-matching-anything"),
):
yield
@pytest.fixture @pytest.fixture
def hassio_env(): def hassio_env():
"""Fixture to inject hassio env.""" """Fixture to inject hassio env."""
@ -37,6 +48,13 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock):
), patch( ), patch(
"homeassistant.components.hassio.HassIO.get_info", "homeassistant.components.hassio.HassIO.get_info",
side_effect=HassioAPIError(), side_effect=HassioAPIError(),
), patch(
"homeassistant.components.hassio.HassIO.get_ingress_panels",
return_value={"panels": []},
), patch(
"homeassistant.components.hassio.repairs.SupervisorRepairs.setup"
), patch(
"homeassistant.components.hassio.HassIO.refresh_updates"
): ):
hass.state = CoreState.starting hass.state = CoreState.starting
hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {}))
@ -67,13 +85,7 @@ async def hassio_client_supervisor(hass, aiohttp_client, hassio_stubs):
@pytest.fixture @pytest.fixture
def hassio_handler(hass, aioclient_mock): async def hassio_handler(hass, aioclient_mock):
"""Create mock hassio handler.""" """Create mock hassio handler."""
async def get_client_session():
return async_get_clientsession(hass)
websession = hass.loop.run_until_complete(get_client_session())
with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}):
yield HassIO(hass.loop, websession, "127.0.0.1") yield HassIO(hass.loop, async_get_clientsession(hass), "127.0.0.1")

View file

@ -1,13 +1,21 @@
"""The tests for the hassio component.""" """The tests for the hassio component."""
from __future__ import annotations
from typing import Any, Literal
import aiohttp import aiohttp
from aiohttp import hdrs, web
import pytest import pytest
from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.hassio.handler import HassIO, HassioAPIError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
async def test_api_ping(hassio_handler, aioclient_mock: AiohttpClientMocker) -> None: async def test_api_ping(
hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with API ping.""" """Test setup with API ping."""
aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"})
@ -16,7 +24,7 @@ async def test_api_ping(hassio_handler, aioclient_mock: AiohttpClientMocker) ->
async def test_api_ping_error( async def test_api_ping_error(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API ping error.""" """Test setup with API ping error."""
aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "error"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "error"})
@ -26,7 +34,7 @@ async def test_api_ping_error(
async def test_api_ping_exeption( async def test_api_ping_exeption(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API ping exception.""" """Test setup with API ping exception."""
aioclient_mock.get("http://127.0.0.1/supervisor/ping", exc=aiohttp.ClientError()) aioclient_mock.get("http://127.0.0.1/supervisor/ping", exc=aiohttp.ClientError())
@ -35,7 +43,9 @@ async def test_api_ping_exeption(
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
async def test_api_info(hassio_handler, aioclient_mock: AiohttpClientMocker) -> None: async def test_api_info(
hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with API generic info.""" """Test setup with API generic info."""
aioclient_mock.get( aioclient_mock.get(
"http://127.0.0.1/info", "http://127.0.0.1/info",
@ -53,7 +63,7 @@ async def test_api_info(hassio_handler, aioclient_mock: AiohttpClientMocker) ->
async def test_api_info_error( async def test_api_info_error(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Home Assistant info error.""" """Test setup with API Home Assistant info error."""
aioclient_mock.get( aioclient_mock.get(
@ -67,7 +77,7 @@ async def test_api_info_error(
async def test_api_host_info( async def test_api_host_info(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Host info.""" """Test setup with API Host info."""
aioclient_mock.get( aioclient_mock.get(
@ -90,7 +100,7 @@ async def test_api_host_info(
async def test_api_supervisor_info( async def test_api_supervisor_info(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Supervisor info.""" """Test setup with API Supervisor info."""
aioclient_mock.get( aioclient_mock.get(
@ -108,7 +118,9 @@ async def test_api_supervisor_info(
assert data["channel"] == "stable" assert data["channel"] == "stable"
async def test_api_os_info(hassio_handler, aioclient_mock: AiohttpClientMocker) -> None: async def test_api_os_info(
hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with API OS info.""" """Test setup with API OS info."""
aioclient_mock.get( aioclient_mock.get(
"http://127.0.0.1/os/info", "http://127.0.0.1/os/info",
@ -125,7 +137,7 @@ async def test_api_os_info(hassio_handler, aioclient_mock: AiohttpClientMocker)
async def test_api_host_info_error( async def test_api_host_info_error(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Home Assistant info error.""" """Test setup with API Home Assistant info error."""
aioclient_mock.get( aioclient_mock.get(
@ -139,7 +151,7 @@ async def test_api_host_info_error(
async def test_api_core_info( async def test_api_core_info(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Home Assistant Core info.""" """Test setup with API Home Assistant Core info."""
aioclient_mock.get( aioclient_mock.get(
@ -153,7 +165,7 @@ async def test_api_core_info(
async def test_api_core_info_error( async def test_api_core_info_error(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Home Assistant Core info error.""" """Test setup with API Home Assistant Core info error."""
aioclient_mock.get( aioclient_mock.get(
@ -167,7 +179,7 @@ async def test_api_core_info_error(
async def test_api_homeassistant_stop( async def test_api_homeassistant_stop(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Home Assistant stop.""" """Test setup with API Home Assistant stop."""
aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"})
@ -177,7 +189,7 @@ async def test_api_homeassistant_stop(
async def test_api_homeassistant_restart( async def test_api_homeassistant_restart(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Home Assistant restart.""" """Test setup with API Home Assistant restart."""
aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"})
@ -187,7 +199,7 @@ async def test_api_homeassistant_restart(
async def test_api_addon_info( async def test_api_addon_info(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Add-on info.""" """Test setup with API Add-on info."""
aioclient_mock.get( aioclient_mock.get(
@ -201,7 +213,7 @@ async def test_api_addon_info(
async def test_api_addon_stats( async def test_api_addon_stats(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Add-on stats.""" """Test setup with API Add-on stats."""
aioclient_mock.get( aioclient_mock.get(
@ -215,7 +227,7 @@ async def test_api_addon_stats(
async def test_api_discovery_message( async def test_api_discovery_message(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API discovery message.""" """Test setup with API discovery message."""
aioclient_mock.get( aioclient_mock.get(
@ -229,7 +241,7 @@ async def test_api_discovery_message(
async def test_api_retrieve_discovery( async def test_api_retrieve_discovery(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API discovery message.""" """Test setup with API discovery message."""
aioclient_mock.get( aioclient_mock.get(
@ -243,7 +255,7 @@ async def test_api_retrieve_discovery(
async def test_api_ingress_panels( async def test_api_ingress_panels(
hassio_handler, aioclient_mock: AiohttpClientMocker hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test setup with API Ingress panels.""" """Test setup with API Ingress panels."""
aioclient_mock.get( aioclient_mock.get(
@ -267,3 +279,56 @@ async def test_api_ingress_panels(
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
assert data["panels"] assert data["panels"]
assert "slug" in data["panels"] assert "slug" in data["panels"]
@pytest.mark.parametrize(
("api_call", "method", "payload"),
[
["retrieve_discovery_messages", "GET", None],
["refresh_updates", "POST", None],
["update_diagnostics", "POST", True],
],
)
async def test_api_headers(
hass,
aiohttp_raw_server,
socket_enabled,
api_call: str,
method: Literal["GET", "POST"],
payload: Any,
) -> None:
"""Test headers are forwarded correctly."""
received_request = None
async def mock_handler(request):
"""Return OK."""
nonlocal received_request
received_request = request
return web.json_response({"result": "ok", "data": None})
server = await aiohttp_raw_server(mock_handler)
hassio_handler = HassIO(
hass.loop,
async_get_clientsession(hass),
f"{server.host}:{server.port}",
)
api_func = getattr(hassio_handler, api_call)
if payload:
await api_func(payload)
else:
await api_func()
assert received_request is not None
assert received_request.method == method
assert received_request.headers.get("X-Hass-Source") == "core.handler"
if method == "GET":
assert hdrs.CONTENT_TYPE not in received_request.headers
return
assert hdrs.CONTENT_TYPE in received_request.headers
if payload:
assert received_request.headers[hdrs.CONTENT_TYPE] == "application/json"
else:
assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream"

View file

@ -1,63 +1,45 @@
"""The tests for the hassio component.""" """The tests for the hassio component."""
import asyncio import asyncio
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch
from aiohttp import StreamReader from aiohttp import StreamReader
import pytest import pytest
from homeassistant.components.hassio.http import _need_auth
from homeassistant.core import HomeAssistant
from tests.common import MockUser
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
async def test_forward_request( @pytest.fixture
hassio_client, aioclient_mock: AiohttpClientMocker def mock_not_onboarded():
) -> None: """Mock that we're not onboarded."""
"""Test fetching normal path.""" with patch(
aioclient_mock.post("http://127.0.0.1/beer", text="response") "homeassistant.components.hassio.http.async_is_onboarded", return_value=False
):
yield
resp = await hassio_client.post("/api/hassio/beer")
# Check we got right response @pytest.fixture
assert resp.status == HTTPStatus.OK def hassio_user_client(hassio_client, hass_admin_user):
body = await resp.text() """Return a Hass.io HTTP client tied to a non-admin user."""
assert body == "response" hass_admin_user.groups = []
return hassio_client
# Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
"build_type", ["supervisor/info", "homeassistant/update", "host/info"] "path",
)
async def test_auth_required_forward_request(hassio_noauth_client, build_type) -> None:
"""Test auth required for normal request."""
resp = await hassio_noauth_client.post(f"/api/hassio/{build_type}")
# Check we got right response
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.parametrize(
"build_type",
[ [
"app/index.html", "app/entrypoint.js",
"app/hassio-app.html", "addons/bl_b392/logo",
"app/index.html", "addons/bl_b392/icon",
"app/hassio-app.html",
"app/some-chunk.js",
"app/app.js",
], ],
) )
async def test_forward_request_no_auth_for_panel( async def test_forward_request_onboarded_user_get(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_user_client, aioclient_mock: AiohttpClientMocker, path: str
) -> None: ) -> None:
"""Test no auth needed for .""" """Test fetching normal path."""
aioclient_mock.get(f"http://127.0.0.1/{build_type}", text="response") aioclient_mock.get(f"http://127.0.0.1/{path}", text="response")
resp = await hassio_client.get(f"/api/hassio/{build_type}") resp = await hassio_user_client.get(f"/api/hassio/{path}")
# Check we got right response # Check we got right response
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
@ -66,15 +48,68 @@ async def test_forward_request_no_auth_for_panel(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
# We only expect a single header.
assert aioclient_mock.mock_calls[0][3] == {"X-Hass-Source": "core.http"}
async def test_forward_request_no_auth_for_logo( @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"])
hassio_client, aioclient_mock: AiohttpClientMocker async def test_forward_request_onboarded_user_unallowed_methods(
hassio_user_client, aioclient_mock: AiohttpClientMocker, method: str
) -> None: ) -> None:
"""Test no auth needed for logo.""" """Test fetching normal path."""
aioclient_mock.get("http://127.0.0.1/addons/bl_b392/logo", text="response") resp = await hassio_user_client.post("/api/hassio/app/entrypoint.js")
resp = await hassio_client.get("/api/hassio/addons/bl_b392/logo") # Check we got right response
assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED
# Check we did not forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
("bad_path", "expected_status"),
[
# Caught by bullshit filter
("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST),
# The .. is processed, making it an unauthenticated path
("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED),
("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED),
# Unauthenticated path
("supervisor/info", HTTPStatus.UNAUTHORIZED),
("supervisor/logs", HTTPStatus.UNAUTHORIZED),
("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED),
],
)
async def test_forward_request_onboarded_user_unallowed_paths(
hassio_user_client,
aioclient_mock: AiohttpClientMocker,
bad_path: str,
expected_status: int,
) -> None:
"""Test fetching normal path."""
resp = await hassio_user_client.get(f"/api/hassio/{bad_path}")
# Check we got right response
assert resp.status == expected_status
# Check we didn't forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
"path",
[
"app/entrypoint.js",
"addons/bl_b392/logo",
"addons/bl_b392/icon",
],
)
async def test_forward_request_onboarded_noauth_get(
hassio_noauth_client, aioclient_mock: AiohttpClientMocker, path: str
) -> None:
"""Test fetching normal path."""
aioclient_mock.get(f"http://127.0.0.1/{path}", text="response")
resp = await hassio_noauth_client.get(f"/api/hassio/{path}")
# Check we got right response # Check we got right response
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
@ -83,15 +118,73 @@ async def test_forward_request_no_auth_for_logo(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
# We only expect a single header.
assert aioclient_mock.mock_calls[0][3] == {"X-Hass-Source": "core.http"}
async def test_forward_request_no_auth_for_icon( @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"])
hassio_client, aioclient_mock: AiohttpClientMocker async def test_forward_request_onboarded_noauth_unallowed_methods(
hassio_noauth_client, aioclient_mock: AiohttpClientMocker, method: str
) -> None: ) -> None:
"""Test no auth needed for icon.""" """Test fetching normal path."""
aioclient_mock.get("http://127.0.0.1/addons/bl_b392/icon", text="response") resp = await hassio_noauth_client.post("/api/hassio/app/entrypoint.js")
resp = await hassio_client.get("/api/hassio/addons/bl_b392/icon") # Check we got right response
assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED
# Check we did not forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
("bad_path", "expected_status"),
[
# Caught by bullshit filter
("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST),
# The .. is processed, making it an unauthenticated path
("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED),
("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED),
# Unauthenticated path
("supervisor/info", HTTPStatus.UNAUTHORIZED),
("supervisor/logs", HTTPStatus.UNAUTHORIZED),
("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED),
],
)
async def test_forward_request_onboarded_noauth_unallowed_paths(
hassio_noauth_client,
aioclient_mock: AiohttpClientMocker,
bad_path: str,
expected_status: int,
) -> None:
"""Test fetching normal path."""
resp = await hassio_noauth_client.get(f"/api/hassio/{bad_path}")
# Check we got right response
assert resp.status == expected_status
# Check we didn't forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
("path", "authenticated"),
[
("app/entrypoint.js", False),
("addons/bl_b392/logo", False),
("addons/bl_b392/icon", False),
("backups/1234abcd/info", True),
],
)
async def test_forward_request_not_onboarded_get(
hassio_noauth_client,
aioclient_mock: AiohttpClientMocker,
path: str,
authenticated: bool,
mock_not_onboarded,
) -> None:
"""Test fetching normal path."""
aioclient_mock.get(f"http://127.0.0.1/{path}", text="response")
resp = await hassio_noauth_client.get(f"/api/hassio/{path}")
# Check we got right response # Check we got right response
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
@ -100,61 +193,224 @@ async def test_forward_request_no_auth_for_icon(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
expected_headers = {
"X-Hass-Source": "core.http",
}
if authenticated:
expected_headers["Authorization"] = "Bearer 123456"
assert aioclient_mock.mock_calls[0][3] == expected_headers
async def test_forward_log_request( @pytest.mark.parametrize(
hassio_client, aioclient_mock: AiohttpClientMocker "path",
[
"backups/new/upload",
"backups/1234abcd/restore/full",
"backups/1234abcd/restore/partial",
],
)
async def test_forward_request_not_onboarded_post(
hassio_noauth_client,
aioclient_mock: AiohttpClientMocker,
path: str,
mock_not_onboarded,
) -> None: ) -> None:
"""Test fetching normal log path doesn't remove ANSI color escape codes.""" """Test fetching normal path."""
aioclient_mock.get("http://127.0.0.1/beer/logs", text="\033[32mresponse\033[0m") aioclient_mock.get(f"http://127.0.0.1/{path}", text="response")
resp = await hassio_client.get("/api/hassio/beer/logs") resp = await hassio_noauth_client.get(f"/api/hassio/{path}")
# Check we got right response # Check we got right response
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
body = await resp.text() body = await resp.text()
assert body == "\033[32mresponse\033[0m" assert body == "response"
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
# We only expect a single header.
assert aioclient_mock.mock_calls[0][3] == {
"X-Hass-Source": "core.http",
"Authorization": "Bearer 123456",
}
@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"])
async def test_forward_request_not_onboarded_unallowed_methods(
hassio_noauth_client, aioclient_mock: AiohttpClientMocker, method: str
) -> None:
"""Test fetching normal path."""
resp = await hassio_noauth_client.post("/api/hassio/app/entrypoint.js")
# Check we got right response
assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED
# Check we did not forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
("bad_path", "expected_status"),
[
# Caught by bullshit filter
("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST),
# The .. is processed, making it an unauthenticated path
("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED),
("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED),
# Unauthenticated path
("supervisor/info", HTTPStatus.UNAUTHORIZED),
("supervisor/logs", HTTPStatus.UNAUTHORIZED),
("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED),
],
)
async def test_forward_request_not_onboarded_unallowed_paths(
hassio_noauth_client,
aioclient_mock: AiohttpClientMocker,
bad_path: str,
expected_status: int,
mock_not_onboarded,
) -> None:
"""Test fetching normal path."""
resp = await hassio_noauth_client.get(f"/api/hassio/{bad_path}")
# Check we got right response
assert resp.status == expected_status
# Check we didn't forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
("path", "authenticated"),
[
("app/entrypoint.js", False),
("addons/bl_b392/logo", False),
("addons/bl_b392/icon", False),
("backups/1234abcd/info", True),
("supervisor/logs", True),
("addons/bl_b392/logs", True),
],
)
async def test_forward_request_admin_get(
hassio_client,
aioclient_mock: AiohttpClientMocker,
path: str,
authenticated: bool,
) -> None:
"""Test fetching normal path."""
aioclient_mock.get(f"http://127.0.0.1/{path}", text="response")
resp = await hassio_client.get(f"/api/hassio/{path}")
# Check we got right response
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert body == "response"
# Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1
expected_headers = {
"X-Hass-Source": "core.http",
}
if authenticated:
expected_headers["Authorization"] = "Bearer 123456"
assert aioclient_mock.mock_calls[0][3] == expected_headers
@pytest.mark.parametrize(
"path",
[
"backups/new/upload",
"backups/1234abcd/restore/full",
"backups/1234abcd/restore/partial",
],
)
async def test_forward_request_admin_post(
hassio_client,
aioclient_mock: AiohttpClientMocker,
path: str,
) -> None:
"""Test fetching normal path."""
aioclient_mock.get(f"http://127.0.0.1/{path}", text="response")
resp = await hassio_client.get(f"/api/hassio/{path}")
# Check we got right response
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert body == "response"
# Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1
# We only expect a single header.
assert aioclient_mock.mock_calls[0][3] == {
"X-Hass-Source": "core.http",
"Authorization": "Bearer 123456",
}
@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"])
async def test_forward_request_admin_unallowed_methods(
hassio_client, aioclient_mock: AiohttpClientMocker, method: str
) -> None:
"""Test fetching normal path."""
resp = await hassio_client.post("/api/hassio/app/entrypoint.js")
# Check we got right response
assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED
# Check we did not forward command
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.parametrize(
("bad_path", "expected_status"),
[
# Caught by bullshit filter
("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST),
# The .. is processed, making it an unauthenticated path
("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED),
("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED),
# Unauthenticated path
("supervisor/info", HTTPStatus.UNAUTHORIZED),
],
)
async def test_forward_request_admin_unallowed_paths(
hassio_client,
aioclient_mock: AiohttpClientMocker,
bad_path: str,
expected_status: int,
) -> None:
"""Test fetching normal path."""
resp = await hassio_client.get(f"/api/hassio/{bad_path}")
# Check we got right response
assert resp.status == expected_status
# Check we didn't forward command
assert len(aioclient_mock.mock_calls) == 0
async def test_bad_gateway_when_cannot_find_supervisor( async def test_bad_gateway_when_cannot_find_supervisor(
hassio_client, aioclient_mock: AiohttpClientMocker hassio_client, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test we get a bad gateway error if we can't find supervisor.""" """Test we get a bad gateway error if we can't find supervisor."""
aioclient_mock.get("http://127.0.0.1/addons/test/info", exc=asyncio.TimeoutError) aioclient_mock.get("http://127.0.0.1/app/entrypoint.js", exc=asyncio.TimeoutError)
resp = await hassio_client.get("/api/hassio/addons/test/info") resp = await hassio_client.get("/api/hassio/app/entrypoint.js")
assert resp.status == HTTPStatus.BAD_GATEWAY assert resp.status == HTTPStatus.BAD_GATEWAY
async def test_forwarding_user_info(
hassio_client, hass_admin_user: MockUser, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test that we forward user info correctly."""
aioclient_mock.get("http://127.0.0.1/hello")
resp = await hassio_client.get("/api/hassio/hello")
# Check we got right response
assert resp.status == HTTPStatus.OK
assert len(aioclient_mock.mock_calls) == 1
req_headers = aioclient_mock.mock_calls[0][-1]
assert req_headers["X-Hass-User-ID"] == hass_admin_user.id
assert req_headers["X-Hass-Is-Admin"] == "1"
async def test_backup_upload_headers( async def test_backup_upload_headers(
hassio_client, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture hassio_client,
aioclient_mock: AiohttpClientMocker,
caplog: pytest.LogCaptureFixture,
mock_not_onboarded,
) -> None: ) -> None:
"""Test that we forward the full header for backup upload.""" """Test that we forward the full header for backup upload."""
content_type = "multipart/form-data; boundary='--webkit'" content_type = "multipart/form-data; boundary='--webkit'"
aioclient_mock.get("http://127.0.0.1/backups/new/upload") aioclient_mock.post("http://127.0.0.1/backups/new/upload")
resp = await hassio_client.get( resp = await hassio_client.post(
"/api/hassio/backups/new/upload", headers={"Content-Type": content_type} "/api/hassio/backups/new/upload", headers={"Content-Type": content_type}
) )
@ -168,19 +424,19 @@ async def test_backup_upload_headers(
async def test_backup_download_headers( async def test_backup_download_headers(
hassio_client, aioclient_mock: AiohttpClientMocker hassio_client, aioclient_mock: AiohttpClientMocker, mock_not_onboarded
) -> None: ) -> None:
"""Test that we forward the full header for backup download.""" """Test that we forward the full header for backup download."""
content_disposition = "attachment; filename=test.tar" content_disposition = "attachment; filename=test.tar"
aioclient_mock.get( aioclient_mock.get(
"http://127.0.0.1/backups/slug/download", "http://127.0.0.1/backups/1234abcd/download",
headers={ headers={
"Content-Length": "50000000", "Content-Length": "50000000",
"Content-Disposition": content_disposition, "Content-Disposition": content_disposition,
}, },
) )
resp = await hassio_client.get("/api/hassio/backups/slug/download") resp = await hassio_client.get("/api/hassio/backups/1234abcd/download")
# Check we got right response # Check we got right response
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK
@ -190,21 +446,10 @@ async def test_backup_download_headers(
assert resp.headers["Content-Disposition"] == content_disposition assert resp.headers["Content-Disposition"] == content_disposition
def test_need_auth(hass: HomeAssistant) -> None:
"""Test if the requested path needs authentication."""
assert not _need_auth(hass, "addons/test/logo")
assert _need_auth(hass, "backups/new/upload")
assert _need_auth(hass, "supervisor/logs")
hass.data["onboarding"] = False
assert not _need_auth(hass, "backups/new/upload")
assert not _need_auth(hass, "supervisor/logs")
async def test_stream(hassio_client, aioclient_mock: AiohttpClientMocker) -> None: async def test_stream(hassio_client, aioclient_mock: AiohttpClientMocker) -> None:
"""Verify that the request is a stream.""" """Verify that the request is a stream."""
aioclient_mock.get("http://127.0.0.1/test") aioclient_mock.get("http://127.0.0.1/app/entrypoint.js")
await hassio_client.get("/api/hassio/test", data="test") await hassio_client.get("/api/hassio/app/entrypoint.js", data="test")
assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader)

View file

@ -21,7 +21,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker
], ],
) )
async def test_ingress_request_get( async def test_ingress_request_get(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.get( aioclient_mock.get(
@ -29,7 +29,7 @@ async def test_ingress_request_get(
text="test", text="test",
) )
resp = await hassio_client.get( resp = await hassio_noauth_client.get(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -41,7 +41,8 @@ async def test_ingress_request_get(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -63,7 +64,7 @@ async def test_ingress_request_get(
], ],
) )
async def test_ingress_request_post( async def test_ingress_request_post(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.post( aioclient_mock.post(
@ -71,7 +72,7 @@ async def test_ingress_request_post(
text="test", text="test",
) )
resp = await hassio_client.post( resp = await hassio_noauth_client.post(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -83,7 +84,8 @@ async def test_ingress_request_post(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -105,7 +107,7 @@ async def test_ingress_request_post(
], ],
) )
async def test_ingress_request_put( async def test_ingress_request_put(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.put( aioclient_mock.put(
@ -113,7 +115,7 @@ async def test_ingress_request_put(
text="test", text="test",
) )
resp = await hassio_client.put( resp = await hassio_noauth_client.put(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -125,7 +127,8 @@ async def test_ingress_request_put(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -147,7 +150,7 @@ async def test_ingress_request_put(
], ],
) )
async def test_ingress_request_delete( async def test_ingress_request_delete(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.delete( aioclient_mock.delete(
@ -155,7 +158,7 @@ async def test_ingress_request_delete(
text="test", text="test",
) )
resp = await hassio_client.delete( resp = await hassio_noauth_client.delete(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -167,7 +170,8 @@ async def test_ingress_request_delete(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -189,7 +193,7 @@ async def test_ingress_request_delete(
], ],
) )
async def test_ingress_request_patch( async def test_ingress_request_patch(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.patch( aioclient_mock.patch(
@ -197,7 +201,7 @@ async def test_ingress_request_patch(
text="test", text="test",
) )
resp = await hassio_client.patch( resp = await hassio_noauth_client.patch(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -209,7 +213,8 @@ async def test_ingress_request_patch(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -231,7 +236,7 @@ async def test_ingress_request_patch(
], ],
) )
async def test_ingress_request_options( async def test_ingress_request_options(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.options( aioclient_mock.options(
@ -239,7 +244,7 @@ async def test_ingress_request_options(
text="test", text="test",
) )
resp = await hassio_client.options( resp = await hassio_noauth_client.options(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -251,7 +256,8 @@ async def test_ingress_request_options(
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -273,20 +279,21 @@ async def test_ingress_request_options(
], ],
) )
async def test_ingress_websocket( async def test_ingress_websocket(
hassio_client, build_type, aioclient_mock: AiohttpClientMocker hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test no auth needed for .""" """Test no auth needed for ."""
aioclient_mock.get(f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}") aioclient_mock.get(f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}")
# Ignore error because we can setup a full IO infrastructure # Ignore error because we can setup a full IO infrastructure
await hassio_client.ws_connect( await hassio_noauth_client.ws_connect(
f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
# Check we forwarded command # Check we forwarded command
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3]
assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress"
assert ( assert (
aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"]
== f"/api/hassio_ingress/{build_type[0]}" == f"/api/hassio_ingress/{build_type[0]}"
@ -298,7 +305,9 @@ async def test_ingress_websocket(
async def test_ingress_missing_peername( async def test_ingress_missing_peername(
hassio_client, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture hassio_noauth_client,
aioclient_mock: AiohttpClientMocker,
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test hadnling of missing peername.""" """Test hadnling of missing peername."""
aioclient_mock.get( aioclient_mock.get(
@ -314,7 +323,7 @@ async def test_ingress_missing_peername(
return_value=MagicMock(), return_value=MagicMock(),
) as transport_mock: ) as transport_mock:
transport_mock.get_extra_info = get_extra_info transport_mock.get_extra_info = get_extra_info
resp = await hassio_client.get( resp = await hassio_noauth_client.get(
"/api/hassio_ingress/lorem/ipsum", "/api/hassio_ingress/lorem/ipsum",
headers={"X-Test-Header": "beer"}, headers={"X-Test-Header": "beer"},
) )
@ -323,3 +332,19 @@ async def test_ingress_missing_peername(
# Check we got right response # Check we got right response
assert resp.status == HTTPStatus.BAD_REQUEST assert resp.status == HTTPStatus.BAD_REQUEST
async def test_forwarding_paths_as_requested(
hassio_noauth_client, aioclient_mock
) -> None:
"""Test incomnig URLs with double encoding go out as dobule encoded."""
# This double encoded string should be forwarded double-encoded too.
aioclient_mock.get(
"http://127.0.0.1/ingress/mock-token/hello/%252e./world",
text="test",
)
resp = await hassio_noauth_client.get(
"/api/hassio_ingress/mock-token/hello/%252e./world",
)
assert await resp.text() == "test"

View file

@ -153,6 +153,11 @@ async def test_websocket_supervisor_api(
msg = await websocket_client.receive_json() msg = await websocket_client.receive_json()
assert msg["result"]["version_latest"] == "1.0.0" assert msg["result"]["version_latest"] == "1.0.0"
assert aioclient_mock.mock_calls[-1][3] == {
"X-Hass-Source": "core.websocket_api",
"Authorization": "Bearer 123456",
}
async def test_websocket_supervisor_api_error( async def test_websocket_supervisor_api_error(
hassio_env, hassio_env,