Hass.io ingress (#22505)
* Fix API stream of snapshot / Add ingress * fix lint * Fix stream handling * Cleanup api handling * fix typing * Set proxy header * Use header constant * Enable the ingress setup * fix lint * Fix name * Fix tests * fix lint * forward params * Add tests for ingress * Cleanup cookie handling with aiohttp 3.5 * Add more tests * Fix tests * Fix lint * Fix header handling for steam * forward header too * fix lint * fix flakepull/22622/head
parent
42e3e878df
commit
6829ecad9d
|
@ -20,6 +20,7 @@ from .auth import async_setup_auth
|
||||||
from .discovery import async_setup_discovery
|
from .discovery import async_setup_discovery
|
||||||
from .handler import HassIO, HassioAPIError
|
from .handler import HassIO, HassioAPIError
|
||||||
from .http import HassIOView
|
from .http import HassIOView
|
||||||
|
from .ingress import async_setup_ingress
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -270,4 +271,7 @@ async def async_setup(hass, config):
|
||||||
# Init auth Hass.io feature
|
# Init auth Hass.io feature
|
||||||
async_setup_auth(hass)
|
async_setup_auth(hass)
|
||||||
|
|
||||||
|
# Init ingress Hass.io feature
|
||||||
|
async_setup_ingress(hass, host)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -9,6 +9,7 @@ ATTR_UUID = 'uuid'
|
||||||
ATTR_USERNAME = 'username'
|
ATTR_USERNAME = 'username'
|
||||||
ATTR_PASSWORD = 'password'
|
ATTR_PASSWORD = 'password'
|
||||||
|
|
||||||
X_HASSIO = 'X-HASSIO-KEY'
|
X_HASSIO = 'X-Hassio-Key'
|
||||||
X_HASS_USER_ID = 'X-HASS-USER-ID'
|
X_INGRESS_PATH = "X-Ingress-Path"
|
||||||
X_HASS_IS_ADMIN = 'X-HASS-IS-ADMIN'
|
X_HASS_USER_ID = 'X-Hass-User-ID'
|
||||||
|
X_HASS_IS_ADMIN = 'X-Hass-Is-Admin'
|
||||||
|
|
|
@ -3,10 +3,11 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.hdrs import CONTENT_TYPE
|
from aiohttp.hdrs import CONTENT_TYPE, CONTENT_LENGTH
|
||||||
from aiohttp.web_exceptions import HTTPBadGateway
|
from aiohttp.web_exceptions import HTTPBadGateway
|
||||||
import async_timeout
|
import async_timeout
|
||||||
|
|
||||||
|
@ -20,7 +21,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
NO_TIMEOUT = re.compile(
|
NO_TIMEOUT = re.compile(
|
||||||
r'^(?:'
|
r'^(?:'
|
||||||
r'|homeassistant/update'
|
r'|homeassistant/update'
|
||||||
r'|host/update'
|
r'|hassos/update'
|
||||||
|
r'|hassos/update/cli'
|
||||||
r'|supervisor/update'
|
r'|supervisor/update'
|
||||||
r'|addons/[^/]+/(?:update|install|rebuild)'
|
r'|addons/[^/]+/(?:update|install|rebuild)'
|
||||||
r'|snapshots/.+/full'
|
r'|snapshots/.+/full'
|
||||||
|
@ -44,25 +46,26 @@ class HassIOView(HomeAssistantView):
|
||||||
url = "/api/hassio/{path:.+}"
|
url = "/api/hassio/{path:.+}"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, host, websession):
|
def __init__(self, host: str, websession: aiohttp.ClientSession):
|
||||||
"""Initialize a Hass.io base view."""
|
"""Initialize a Hass.io base view."""
|
||||||
self._host = host
|
self._host = host
|
||||||
self._websession = websession
|
self._websession = websession
|
||||||
|
|
||||||
async def _handle(self, request, path):
|
async def _handle(
|
||||||
|
self, request: web.Request, path: str
|
||||||
|
) -> Union[web.Response, web.StreamResponse]:
|
||||||
"""Route data to Hass.io."""
|
"""Route data to Hass.io."""
|
||||||
if _need_auth(path) and not request[KEY_AUTHENTICATED]:
|
if _need_auth(path) and not request[KEY_AUTHENTICATED]:
|
||||||
return web.Response(status=401)
|
return web.Response(status=401)
|
||||||
|
|
||||||
client = await self._command_proxy(path, request)
|
return await self._command_proxy(path, request)
|
||||||
|
|
||||||
data = await client.read()
|
|
||||||
return _create_response(client, data)
|
|
||||||
|
|
||||||
get = _handle
|
get = _handle
|
||||||
post = _handle
|
post = _handle
|
||||||
|
|
||||||
async def _command_proxy(self, path, request):
|
async def _command_proxy(
|
||||||
|
self, path: str, request: web.Request
|
||||||
|
) -> Union[web.Response, 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.
|
This method is a coroutine.
|
||||||
|
@ -71,29 +74,38 @@ class HassIOView(HomeAssistantView):
|
||||||
hass = request.app['hass']
|
hass = request.app['hass']
|
||||||
|
|
||||||
data = None
|
data = None
|
||||||
headers = {
|
headers = _init_header(request)
|
||||||
X_HASSIO: os.environ.get('HASSIO_TOKEN', ""),
|
|
||||||
}
|
|
||||||
user = request.get('hass_user')
|
|
||||||
if user is not None:
|
|
||||||
headers[X_HASS_USER_ID] = request['hass_user'].id
|
|
||||||
headers[X_HASS_IS_ADMIN] = str(int(request['hass_user'].is_admin))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with async_timeout.timeout(10, loop=hass.loop):
|
with async_timeout.timeout(10, loop=hass.loop):
|
||||||
data = await request.read()
|
data = await request.read()
|
||||||
if data:
|
|
||||||
headers[CONTENT_TYPE] = request.content_type
|
|
||||||
else:
|
|
||||||
data = None
|
|
||||||
|
|
||||||
method = getattr(self._websession, request.method.lower())
|
method = getattr(self._websession, request.method.lower())
|
||||||
client = await method(
|
client = await method(
|
||||||
"http://{}/{}".format(self._host, path), data=data,
|
"http://{}/{}".format(self._host, path), data=data,
|
||||||
headers=headers, timeout=read_timeout
|
headers=headers, timeout=read_timeout
|
||||||
)
|
)
|
||||||
|
print(client.headers)
|
||||||
|
|
||||||
return client
|
# Simple request
|
||||||
|
if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000:
|
||||||
|
# Return Response
|
||||||
|
body = await client.read()
|
||||||
|
return web.Response(
|
||||||
|
content_type=client.content_type,
|
||||||
|
status=client.status,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream response
|
||||||
|
response = web.StreamResponse(status=client.status)
|
||||||
|
response.content_type = client.content_type
|
||||||
|
|
||||||
|
await response.prepare(request)
|
||||||
|
async for data in client.content.iter_chunked(4096):
|
||||||
|
await response.write(data)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error("Client error on api %s request %s", path, err)
|
_LOGGER.error("Client error on api %s request %s", path, err)
|
||||||
|
@ -104,23 +116,30 @@ class HassIOView(HomeAssistantView):
|
||||||
raise HTTPBadGateway()
|
raise HTTPBadGateway()
|
||||||
|
|
||||||
|
|
||||||
def _create_response(client, data):
|
def _init_header(request: web.Request) -> Dict[str, str]:
|
||||||
"""Convert a response from client request."""
|
"""Create initial header."""
|
||||||
return web.Response(
|
headers = {
|
||||||
body=data,
|
X_HASSIO: os.environ.get('HASSIO_TOKEN', ""),
|
||||||
status=client.status,
|
CONTENT_TYPE: request.content_type,
|
||||||
content_type=client.content_type,
|
}
|
||||||
)
|
|
||||||
|
# Add user data
|
||||||
|
user = request.get('hass_user')
|
||||||
|
if user is not None:
|
||||||
|
headers[X_HASS_USER_ID] = request['hass_user'].id
|
||||||
|
headers[X_HASS_IS_ADMIN] = str(int(request['hass_user'].is_admin))
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
def _get_timeout(path):
|
def _get_timeout(path: str) -> int:
|
||||||
"""Return timeout for a URL path."""
|
"""Return timeout for a URL path."""
|
||||||
if NO_TIMEOUT.match(path):
|
if NO_TIMEOUT.match(path):
|
||||||
return 0
|
return 0
|
||||||
return 300
|
return 300
|
||||||
|
|
||||||
|
|
||||||
def _need_auth(path):
|
def _need_auth(path: str) -> bool:
|
||||||
"""Return if a path need authentication."""
|
"""Return if a path need authentication."""
|
||||||
if NO_AUTH.match(path):
|
if NO_AUTH.match(path):
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""Hass.io Add-on ingress service."""
|
||||||
|
import asyncio
|
||||||
|
from ipaddress import ip_address
|
||||||
|
import os
|
||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp import hdrs
|
||||||
|
from aiohttp.web_exceptions import HTTPBadGateway
|
||||||
|
from multidict import CIMultiDict
|
||||||
|
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
|
||||||
|
from .const import X_HASSIO, X_INGRESS_PATH
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup_ingress(hass: HomeAssistantType, host: str):
|
||||||
|
"""Auth setup."""
|
||||||
|
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||||
|
|
||||||
|
hassio_ingress = HassIOIngress(host, websession)
|
||||||
|
hass.http.register_view(hassio_ingress)
|
||||||
|
|
||||||
|
|
||||||
|
class HassIOIngress(HomeAssistantView):
|
||||||
|
"""Hass.io view to handle base part."""
|
||||||
|
|
||||||
|
name = "api:hassio:ingress"
|
||||||
|
url = "/api/hassio_ingress/{addon}/{path:.+}"
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, host: str, websession: aiohttp.ClientSession):
|
||||||
|
"""Initialize a Hass.io ingress view."""
|
||||||
|
self._host = host
|
||||||
|
self._websession = websession
|
||||||
|
|
||||||
|
def _create_url(self, addon: str, path: str) -> str:
|
||||||
|
"""Create URL to service."""
|
||||||
|
return "http://{}/addons/{}/web/{}".format(self._host, addon, path)
|
||||||
|
|
||||||
|
async def _handle(
|
||||||
|
self, request: web.Request, addon: str, path: str
|
||||||
|
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
|
||||||
|
"""Route data to Hass.io ingress service."""
|
||||||
|
try:
|
||||||
|
# Websocket
|
||||||
|
if _is_websocket(request):
|
||||||
|
return await self._handle_websocket(request, addon, path)
|
||||||
|
|
||||||
|
# Request
|
||||||
|
return await self._handle_request(request, addon, path)
|
||||||
|
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise HTTPBadGateway() from None
|
||||||
|
|
||||||
|
get = _handle
|
||||||
|
post = _handle
|
||||||
|
put = _handle
|
||||||
|
delete = _handle
|
||||||
|
|
||||||
|
async def _handle_websocket(
|
||||||
|
self, request: web.Request, addon: str, path: str
|
||||||
|
) -> web.WebSocketResponse:
|
||||||
|
"""Ingress route for websocket."""
|
||||||
|
ws_server = web.WebSocketResponse()
|
||||||
|
await ws_server.prepare(request)
|
||||||
|
|
||||||
|
url = self._create_url(addon, path)
|
||||||
|
source_header = _init_header(request, addon)
|
||||||
|
|
||||||
|
# Start proxy
|
||||||
|
async with self._websession.ws_connect(
|
||||||
|
url, headers=source_header
|
||||||
|
) as ws_client:
|
||||||
|
# Proxy requests
|
||||||
|
await asyncio.wait(
|
||||||
|
[
|
||||||
|
_websocket_forward(ws_server, ws_client),
|
||||||
|
_websocket_forward(ws_client, ws_server),
|
||||||
|
],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
|
||||||
|
return ws_server
|
||||||
|
|
||||||
|
async def _handle_request(
|
||||||
|
self, request: web.Request, addon: str, path: str
|
||||||
|
) -> Union[web.Response, web.StreamResponse]:
|
||||||
|
"""Ingress route for request."""
|
||||||
|
url = self._create_url(addon, path)
|
||||||
|
data = await request.read()
|
||||||
|
source_header = _init_header(request, addon)
|
||||||
|
|
||||||
|
async with self._websession.request(
|
||||||
|
request.method, url, headers=source_header,
|
||||||
|
params=request.query, data=data, cookies=request.cookies
|
||||||
|
) as result:
|
||||||
|
headers = _response_header(result)
|
||||||
|
|
||||||
|
# Simple request
|
||||||
|
if hdrs.CONTENT_LENGTH in result.headers and \
|
||||||
|
int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4194000:
|
||||||
|
# Return Response
|
||||||
|
body = await result.read()
|
||||||
|
return web.Response(
|
||||||
|
headers=headers,
|
||||||
|
status=result.status,
|
||||||
|
body=body
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream response
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=result.status, headers=headers)
|
||||||
|
response.content_type = result.content_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
await response.prepare(request)
|
||||||
|
async for data in result.content:
|
||||||
|
await response.write(data)
|
||||||
|
|
||||||
|
except (aiohttp.ClientError, aiohttp.ClientPayloadError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _init_header(
|
||||||
|
request: web.Request, addon: str
|
||||||
|
) -> Union[CIMultiDict, Dict[str, str]]:
|
||||||
|
"""Create initial header."""
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
# filter flags
|
||||||
|
for name, value in request.headers.items():
|
||||||
|
if name in (hdrs.CONTENT_LENGTH, hdrs.CONTENT_TYPE):
|
||||||
|
continue
|
||||||
|
headers[name] = value
|
||||||
|
|
||||||
|
# Inject token / cleanup later on Supervisor
|
||||||
|
headers[X_HASSIO] = os.environ.get('HASSIO_TOKEN', "")
|
||||||
|
|
||||||
|
# Ingress information
|
||||||
|
headers[X_INGRESS_PATH] = "/api/hassio_ingress/{}".format(addon)
|
||||||
|
|
||||||
|
# Set X-Forwarded-For
|
||||||
|
forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
|
||||||
|
connected_ip = ip_address(request.transport.get_extra_info('peername')[0])
|
||||||
|
if forward_for:
|
||||||
|
forward_for = "{}, {!s}".format(forward_for, connected_ip)
|
||||||
|
else:
|
||||||
|
forward_for = "{!s}".format(connected_ip)
|
||||||
|
headers[hdrs.X_FORWARDED_FOR] = forward_for
|
||||||
|
|
||||||
|
# Set X-Forwarded-Host
|
||||||
|
forward_host = request.headers.get(hdrs.X_FORWARDED_HOST)
|
||||||
|
if not forward_host:
|
||||||
|
forward_host = request.host
|
||||||
|
headers[hdrs.X_FORWARDED_HOST] = forward_host
|
||||||
|
|
||||||
|
# Set X-Forwarded-Proto
|
||||||
|
forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO)
|
||||||
|
if not forward_proto:
|
||||||
|
forward_proto = request.url.scheme
|
||||||
|
headers[hdrs.X_FORWARDED_PROTO] = forward_proto
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
|
||||||
|
"""Create response header."""
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
for name, value in response.headers.items():
|
||||||
|
if name in (hdrs.TRANSFER_ENCODING, hdrs.CONTENT_LENGTH,
|
||||||
|
hdrs.CONTENT_TYPE):
|
||||||
|
continue
|
||||||
|
headers[name] = value
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _is_websocket(request: web.Request) -> bool:
|
||||||
|
"""Return True if request is a websocket."""
|
||||||
|
headers = request.headers
|
||||||
|
|
||||||
|
if headers.get(hdrs.CONNECTION) == "Upgrade" and \
|
||||||
|
headers.get(hdrs.UPGRADE) == "websocket":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _websocket_forward(ws_from, ws_to):
|
||||||
|
"""Handle websocket message directly."""
|
||||||
|
async for msg in ws_from:
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
await ws_to.send_str(msg.data)
|
||||||
|
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
await ws_to.send_bytes(msg.data)
|
||||||
|
elif msg.type == aiohttp.WSMsgType.PING:
|
||||||
|
await ws_to.ping()
|
||||||
|
elif msg.type == aiohttp.WSMsgType.PONG:
|
||||||
|
await ws_to.pong()
|
||||||
|
elif ws_to.closed:
|
||||||
|
await ws_to.close(code=ws_to.close_code, message=msg.extra)
|
|
@ -1,26 +1,19 @@
|
||||||
"""The tests for the hassio component."""
|
"""The tests for the hassio component."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import patch, Mock, MagicMock
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.const import HTTP_HEADER_HA_AUTH
|
from homeassistant.const import HTTP_HEADER_HA_AUTH
|
||||||
|
|
||||||
from tests.common import mock_coro
|
|
||||||
from . import API_PASSWORD
|
from . import API_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
def test_forward_request(hassio_client):
|
def test_forward_request(hassio_client, aioclient_mock):
|
||||||
"""Test fetching normal path."""
|
"""Test fetching normal path."""
|
||||||
response = MagicMock()
|
aioclient_mock.post("http://127.0.0.1/beer", text="response")
|
||||||
response.read.return_value = mock_coro('data')
|
|
||||||
|
|
||||||
with patch('homeassistant.components.hassio.HassIOView._command_proxy',
|
|
||||||
Mock(return_value=mock_coro(response))), \
|
|
||||||
patch('homeassistant.components.hassio.http'
|
|
||||||
'._create_response') as mresp:
|
|
||||||
mresp.return_value = 'response'
|
|
||||||
resp = yield from hassio_client.post('/api/hassio/beer', headers={
|
resp = yield from hassio_client.post('/api/hassio/beer', headers={
|
||||||
HTTP_HEADER_HA_AUTH: API_PASSWORD
|
HTTP_HEADER_HA_AUTH: API_PASSWORD
|
||||||
})
|
})
|
||||||
|
@ -31,8 +24,7 @@ def test_forward_request(hassio_client):
|
||||||
assert body == 'response'
|
assert body == 'response'
|
||||||
|
|
||||||
# Check we forwarded command
|
# Check we forwarded command
|
||||||
assert len(mresp.mock_calls) == 1
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
assert mresp.mock_calls[0][1] == (response, 'data')
|
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
|
@ -55,18 +47,13 @@ def test_auth_required_forward_request(hassio_noauth_client, build_type):
|
||||||
'app/index.html', 'app/hassio-app.html', 'app/index.html',
|
'app/index.html', 'app/hassio-app.html', 'app/index.html',
|
||||||
'app/hassio-app.html', 'app/some-chunk.js', 'app/app.js',
|
'app/hassio-app.html', 'app/some-chunk.js', 'app/app.js',
|
||||||
])
|
])
|
||||||
def test_forward_request_no_auth_for_panel(hassio_client, build_type):
|
def test_forward_request_no_auth_for_panel(
|
||||||
|
hassio_client, build_type, aioclient_mock):
|
||||||
"""Test no auth needed for ."""
|
"""Test no auth needed for ."""
|
||||||
response = MagicMock()
|
aioclient_mock.get(
|
||||||
response.read.return_value = mock_coro('data')
|
"http://127.0.0.1/{}".format(build_type), text="response")
|
||||||
|
|
||||||
with patch('homeassistant.components.hassio.HassIOView._command_proxy',
|
resp = yield from hassio_client.get('/api/hassio/{}'.format(build_type))
|
||||||
Mock(return_value=mock_coro(response))), \
|
|
||||||
patch('homeassistant.components.hassio.http.'
|
|
||||||
'_create_response') as mresp:
|
|
||||||
mresp.return_value = 'response'
|
|
||||||
resp = yield from hassio_client.get(
|
|
||||||
'/api/hassio/{}'.format(build_type))
|
|
||||||
|
|
||||||
# Check we got right response
|
# Check we got right response
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
|
@ -74,21 +61,15 @@ def test_forward_request_no_auth_for_panel(hassio_client, build_type):
|
||||||
assert body == 'response'
|
assert body == 'response'
|
||||||
|
|
||||||
# Check we forwarded command
|
# Check we forwarded command
|
||||||
assert len(mresp.mock_calls) == 1
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
assert mresp.mock_calls[0][1] == (response, 'data')
|
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
def test_forward_request_no_auth_for_logo(hassio_client):
|
def test_forward_request_no_auth_for_logo(hassio_client, aioclient_mock):
|
||||||
"""Test no auth needed for ."""
|
"""Test no auth needed for ."""
|
||||||
response = MagicMock()
|
aioclient_mock.get(
|
||||||
response.read.return_value = mock_coro('data')
|
"http://127.0.0.1/addons/bl_b392/logo", text="response")
|
||||||
|
|
||||||
with patch('homeassistant.components.hassio.HassIOView._command_proxy',
|
|
||||||
Mock(return_value=mock_coro(response))), \
|
|
||||||
patch('homeassistant.components.hassio.http.'
|
|
||||||
'_create_response') as mresp:
|
|
||||||
mresp.return_value = 'response'
|
|
||||||
resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo')
|
resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo')
|
||||||
|
|
||||||
# Check we got right response
|
# Check we got right response
|
||||||
|
@ -97,21 +78,15 @@ def test_forward_request_no_auth_for_logo(hassio_client):
|
||||||
assert body == 'response'
|
assert body == 'response'
|
||||||
|
|
||||||
# Check we forwarded command
|
# Check we forwarded command
|
||||||
assert len(mresp.mock_calls) == 1
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
assert mresp.mock_calls[0][1] == (response, 'data')
|
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
def test_forward_log_request(hassio_client):
|
def test_forward_log_request(hassio_client, aioclient_mock):
|
||||||
"""Test fetching normal log path doesn't remove ANSI color escape codes."""
|
"""Test fetching normal log path doesn't remove ANSI color escape codes."""
|
||||||
response = MagicMock()
|
aioclient_mock.get(
|
||||||
response.read.return_value = mock_coro('data')
|
"http://127.0.0.1/beer/logs", text="\033[32mresponse\033[0m")
|
||||||
|
|
||||||
with patch('homeassistant.components.hassio.HassIOView._command_proxy',
|
|
||||||
Mock(return_value=mock_coro(response))), \
|
|
||||||
patch('homeassistant.components.hassio.http.'
|
|
||||||
'_create_response') as mresp:
|
|
||||||
mresp.return_value = '\033[32mresponse\033[0m'
|
|
||||||
resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={
|
resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={
|
||||||
HTTP_HEADER_HA_AUTH: API_PASSWORD
|
HTTP_HEADER_HA_AUTH: API_PASSWORD
|
||||||
})
|
})
|
||||||
|
@ -122,8 +97,7 @@ def test_forward_log_request(hassio_client):
|
||||||
assert body == '\033[32mresponse\033[0m'
|
assert body == '\033[32mresponse\033[0m'
|
||||||
|
|
||||||
# Check we forwarded command
|
# Check we forwarded command
|
||||||
assert len(mresp.mock_calls) == 1
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
assert mresp.mock_calls[0][1] == (response, 'data')
|
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
|
@ -151,5 +125,5 @@ async def test_forwarding_user_info(hassio_client, hass_admin_user,
|
||||||
assert len(aioclient_mock.mock_calls) == 1
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
|
||||||
req_headers = aioclient_mock.mock_calls[0][-1]
|
req_headers = aioclient_mock.mock_calls[0][-1]
|
||||||
req_headers['X-HASS-USER-ID'] == hass_admin_user.id
|
req_headers['X-Hass-User-ID'] == hass_admin_user.id
|
||||||
req_headers['X-HASS-IS-ADMIN'] == '1'
|
req_headers['X-Hass-Is-Admin'] == '1'
|
||||||
|
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""The tests for the hassio component."""
|
||||||
|
|
||||||
|
from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO
|
||||||
|
from aiohttp.client_exceptions import WSServerHandshakeError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'build_type', [
|
||||||
|
("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
|
||||||
|
("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5")
|
||||||
|
])
|
||||||
|
async def test_ingress_request_get(
|
||||||
|
hassio_client, build_type, aioclient_mock):
|
||||||
|
"""Test no auth needed for ."""
|
||||||
|
aioclient_mock.get("http://127.0.0.1/addons/{}/web/{}".format(
|
||||||
|
build_type[0], build_type[1]), text="test")
|
||||||
|
|
||||||
|
resp = await hassio_client.get(
|
||||||
|
'/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
|
||||||
|
headers={"X-Test-Header": "beer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check we got right response
|
||||||
|
assert resp.status == 200
|
||||||
|
body = await resp.text()
|
||||||
|
assert body == "test"
|
||||||
|
|
||||||
|
# Check we forwarded command
|
||||||
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
|
||||||
|
"/api/hassio_ingress/{}".format(build_type[0])
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'build_type', [
|
||||||
|
("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
|
||||||
|
("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5")
|
||||||
|
])
|
||||||
|
async def test_ingress_request_post(
|
||||||
|
hassio_client, build_type, aioclient_mock):
|
||||||
|
"""Test no auth needed for ."""
|
||||||
|
aioclient_mock.post("http://127.0.0.1/addons/{}/web/{}".format(
|
||||||
|
build_type[0], build_type[1]), text="test")
|
||||||
|
|
||||||
|
resp = await hassio_client.post(
|
||||||
|
'/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
|
||||||
|
headers={"X-Test-Header": "beer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check we got right response
|
||||||
|
assert resp.status == 200
|
||||||
|
body = await resp.text()
|
||||||
|
assert body == "test"
|
||||||
|
|
||||||
|
# Check we forwarded command
|
||||||
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
|
||||||
|
"/api/hassio_ingress/{}".format(build_type[0])
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'build_type', [
|
||||||
|
("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
|
||||||
|
("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5")
|
||||||
|
])
|
||||||
|
async def test_ingress_request_put(
|
||||||
|
hassio_client, build_type, aioclient_mock):
|
||||||
|
"""Test no auth needed for ."""
|
||||||
|
aioclient_mock.put("http://127.0.0.1/addons/{}/web/{}".format(
|
||||||
|
build_type[0], build_type[1]), text="test")
|
||||||
|
|
||||||
|
resp = await hassio_client.put(
|
||||||
|
'/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
|
||||||
|
headers={"X-Test-Header": "beer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check we got right response
|
||||||
|
assert resp.status == 200
|
||||||
|
body = await resp.text()
|
||||||
|
assert body == "test"
|
||||||
|
|
||||||
|
# Check we forwarded command
|
||||||
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
|
||||||
|
"/api/hassio_ingress/{}".format(build_type[0])
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'build_type', [
|
||||||
|
("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
|
||||||
|
("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5")
|
||||||
|
])
|
||||||
|
async def test_ingress_request_delete(
|
||||||
|
hassio_client, build_type, aioclient_mock):
|
||||||
|
"""Test no auth needed for ."""
|
||||||
|
aioclient_mock.delete("http://127.0.0.1/addons/{}/web/{}".format(
|
||||||
|
build_type[0], build_type[1]), text="test")
|
||||||
|
|
||||||
|
resp = await hassio_client.delete(
|
||||||
|
'/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
|
||||||
|
headers={"X-Test-Header": "beer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check we got right response
|
||||||
|
assert resp.status == 200
|
||||||
|
body = await resp.text()
|
||||||
|
assert body == "test"
|
||||||
|
|
||||||
|
# Check we forwarded command
|
||||||
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
|
||||||
|
"/api/hassio_ingress/{}".format(build_type[0])
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'build_type', [
|
||||||
|
("a3_vl", "test/beer/ws"), ("core", "ws.php"),
|
||||||
|
("local", "panel/config/stream"), ("jk_921", "hulk")
|
||||||
|
])
|
||||||
|
async def test_ingress_websocket(
|
||||||
|
hassio_client, build_type, aioclient_mock):
|
||||||
|
"""Test no auth needed for ."""
|
||||||
|
aioclient_mock.get("http://127.0.0.1/addons/{}/web/{}".format(
|
||||||
|
build_type[0], build_type[1]))
|
||||||
|
|
||||||
|
# Ignore error because we can setup a full IO infrastructure
|
||||||
|
with pytest.raises(WSServerHandshakeError):
|
||||||
|
await hassio_client.ws_connect(
|
||||||
|
'/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
|
||||||
|
headers={"X-Test-Header": "beer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check we forwarded command
|
||||||
|
assert len(aioclient_mock.mock_calls) == 1
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
|
||||||
|
"/api/hassio_ingress/{}".format(build_type[0])
|
||||||
|
assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
|
||||||
|
assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
|
|
@ -207,7 +207,7 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock):
|
||||||
assert result
|
assert result
|
||||||
|
|
||||||
assert aioclient_mock.call_count == 3
|
assert aioclient_mock.call_count == 3
|
||||||
assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456"
|
assert aioclient_mock.mock_calls[-1][3]['X-Hassio-Key'] == "123456"
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
|
|
|
@ -102,7 +102,7 @@ class AiohttpClientMocker:
|
||||||
|
|
||||||
async def match_request(self, method, url, *, data=None, auth=None,
|
async def match_request(self, method, url, *, data=None, auth=None,
|
||||||
params=None, headers=None, allow_redirects=None,
|
params=None, headers=None, allow_redirects=None,
|
||||||
timeout=None, json=None):
|
timeout=None, json=None, cookies=None):
|
||||||
"""Match a request against pre-registered requests."""
|
"""Match a request against pre-registered requests."""
|
||||||
data = data or json
|
data = data or json
|
||||||
url = URL(url)
|
url = URL(url)
|
||||||
|
|
Loading…
Reference in New Issue