mirror of
https://github.com/home-assistant/core.git
synced 2026-07-12 17:18:04 +01:00
c6939ae93d
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Robert Resch <robert@resch.dev> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1514 lines
50 KiB
Python
1514 lines
50 KiB
Python
"""The tests for the Home Assistant HTTP component."""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from http import HTTPStatus
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import ANY, Mock, patch
|
|
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
import pytest
|
|
|
|
from homeassistant.auth.providers.homeassistant import HassAuthProvider
|
|
from homeassistant.components import cloud, http
|
|
from homeassistant.components.cloud import CloudNotAvailable
|
|
from homeassistant.components.http import DOMAIN
|
|
from homeassistant.components.http.config import (
|
|
_DEFAULT_CONFIG,
|
|
AUTO_REVERT_DELAY,
|
|
HTTP_STORAGE_SCHEMA,
|
|
default_server_port,
|
|
)
|
|
from homeassistant.components.http.const import ENV_SETUP_PORT
|
|
from homeassistant.const import HASSIO_USER_NAME
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import issue_registry as ir
|
|
from homeassistant.helpers.http import KEY_HASS
|
|
from homeassistant.helpers.network import NoURLAvailableError
|
|
from homeassistant.setup import async_setup_component
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.ssl import server_context_intermediate, server_context_modern
|
|
|
|
from tests.common import (
|
|
async_call_logger_set_level,
|
|
async_fire_time_changed,
|
|
async_mock_service,
|
|
)
|
|
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def disable_http_server(socket_enabled: None) -> None:
|
|
"""Override the global disable_http_server fixture with an empty fixture.
|
|
|
|
This allows the HTTP server to start in tests that need it.
|
|
"""
|
|
return
|
|
|
|
|
|
def _setup_broken_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path]:
|
|
test_dir = tmp_path / "test_broken_ssl"
|
|
test_dir.mkdir()
|
|
cert_path = test_dir / "cert.pem"
|
|
cert_path.write_text("garbage")
|
|
key_path = test_dir / "key.pem"
|
|
key_path.write_text("garbage")
|
|
return cert_path, key_path
|
|
|
|
|
|
def _setup_empty_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
test_dir = tmp_path / "test_empty_ssl"
|
|
test_dir.mkdir()
|
|
cert_path = test_dir / "cert.pem"
|
|
cert_path.write_text("-")
|
|
peer_cert_path = test_dir / "peer_cert.pem"
|
|
peer_cert_path.write_text("-")
|
|
key_path = test_dir / "key.pem"
|
|
key_path.write_text("-")
|
|
return cert_path, key_path, peer_cert_path
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_stack():
|
|
"""Mock extract stack."""
|
|
with patch(
|
|
"homeassistant.components.http.extract_stack",
|
|
return_value=[
|
|
Mock(
|
|
filename="/home/paulus/core/homeassistant/core.py",
|
|
lineno="23",
|
|
line="do_something()",
|
|
),
|
|
Mock(
|
|
filename="/home/paulus/core/homeassistant/components/hue/light.py",
|
|
lineno="23",
|
|
line="self.light.is_on",
|
|
),
|
|
Mock(
|
|
filename="/home/paulus/core/homeassistant/components/http/__init__.py",
|
|
lineno="157",
|
|
line="base_url",
|
|
),
|
|
],
|
|
):
|
|
yield
|
|
|
|
|
|
class TestView(http.HomeAssistantView):
|
|
"""Test the HTTP views."""
|
|
|
|
name = "test"
|
|
url = "/hello"
|
|
|
|
async def get(self, request):
|
|
"""Return a get request."""
|
|
return "hello"
|
|
|
|
|
|
async def test_registering_view_while_running(
|
|
hass: HomeAssistant,
|
|
aiohttp_client: ClientSessionGenerator,
|
|
unused_tcp_port_factory: Callable[[], int],
|
|
) -> None:
|
|
"""Test that we can register a view while the server is running."""
|
|
await async_setup_component(
|
|
hass,
|
|
http.DOMAIN,
|
|
{http.DOMAIN: {http.CONF_SERVER_PORT: unused_tcp_port_factory()}},
|
|
)
|
|
|
|
await hass.async_start()
|
|
# This raises a RuntimeError if app is frozen
|
|
hass.http.register_view(TestView)
|
|
|
|
|
|
async def test_homeassistant_assigned_to_app(hass: HomeAssistant) -> None:
|
|
"""Test HomeAssistant instance is assigned to HomeAssistantApp."""
|
|
assert await async_setup_component(hass, "api", {"http": {}})
|
|
await hass.async_start()
|
|
assert hass.http.app[KEY_HASS] == hass
|
|
assert hass.http.app["hass"] == hass # For backwards compatibility
|
|
await hass.async_stop()
|
|
|
|
|
|
async def test_not_log_password(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
caplog: pytest.LogCaptureFixture,
|
|
local_auth: HassAuthProvider,
|
|
) -> None:
|
|
"""Test access with password doesn't get logged."""
|
|
assert await async_setup_component(hass, "api", {"http": {}})
|
|
client = await hass_client_no_auth()
|
|
logging.getLogger("aiohttp.access").setLevel(logging.INFO)
|
|
|
|
resp = await client.get("/api/", params={"api_password": "test-password"})
|
|
|
|
assert resp.status == HTTPStatus.UNAUTHORIZED
|
|
logs = caplog.text
|
|
|
|
# Ensure we don't log API passwords
|
|
assert "/api/" in logs
|
|
assert "some-pass" not in logs
|
|
|
|
|
|
async def test_proxy_config(hass: HomeAssistant) -> None:
|
|
"""Test use_x_forwarded_for must config together with trusted_proxies."""
|
|
assert (
|
|
await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"http": {
|
|
http.CONF_USE_X_FORWARDED_FOR: True,
|
|
http.CONF_TRUSTED_PROXIES: ["127.0.0.1"],
|
|
}
|
|
},
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
async def test_proxy_config_forwarded_request(
|
|
hass: HomeAssistant,
|
|
hass_client: ClientSessionGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test a request with X-Forwarded-For from a trusted proxy is accepted.
|
|
|
|
The config store persists trusted proxies as strings; this exercises the
|
|
forwarded middleware with a config loaded from the store to guard against
|
|
passing the strings through instead of IP network objects.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/8"],
|
|
}
|
|
)
|
|
assert await async_setup_component(hass, "api", {})
|
|
client = await hass_client()
|
|
|
|
resp = await client.get("/api/", headers={"X-Forwarded-For": "203.0.113.5"})
|
|
|
|
assert resp.status == HTTPStatus.OK
|
|
|
|
|
|
async def test_proxy_config_only_use_xff(hass: HomeAssistant) -> None:
|
|
"""Test use_x_forwarded_for must config together with trusted_proxies."""
|
|
assert (
|
|
await async_setup_component(
|
|
hass, DOMAIN, {"http": {http.CONF_USE_X_FORWARDED_FOR: True}}
|
|
)
|
|
is not True
|
|
)
|
|
|
|
|
|
async def test_proxy_config_only_trust_proxies(hass: HomeAssistant) -> None:
|
|
"""Test use_x_forwarded_for must config together with trusted_proxies."""
|
|
assert (
|
|
await async_setup_component(
|
|
hass, DOMAIN, {"http": {http.CONF_TRUSTED_PROXIES: ["127.0.0.1"]}}
|
|
)
|
|
is not True
|
|
)
|
|
|
|
|
|
async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmp_path: Path) -> None:
|
|
"""Test default ssl profile."""
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
) as mock_context,
|
|
):
|
|
assert (
|
|
await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
|
|
)
|
|
is True
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(mock_context.mock_calls) == 1
|
|
|
|
|
|
async def test_ssl_profile_change_intermediate(
|
|
hass: HomeAssistant, tmp_path: Path
|
|
) -> None:
|
|
"""Test setting ssl profile to intermediate."""
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_intermediate",
|
|
side_effect=server_context_intermediate,
|
|
) as mock_context,
|
|
):
|
|
assert (
|
|
await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"http": {
|
|
"ssl_profile": "intermediate",
|
|
"ssl_certificate": cert_path,
|
|
"ssl_key": key_path,
|
|
}
|
|
},
|
|
)
|
|
is True
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(mock_context.mock_calls) == 1
|
|
|
|
|
|
async def test_ssl_profile_change_modern(hass: HomeAssistant, tmp_path: Path) -> None:
|
|
"""Test setting ssl profile to modern."""
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
) as mock_context,
|
|
):
|
|
assert (
|
|
await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"http": {
|
|
"ssl_profile": "modern",
|
|
"ssl_certificate": cert_path,
|
|
"ssl_key": key_path,
|
|
}
|
|
},
|
|
)
|
|
is True
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(mock_context.mock_calls) == 1
|
|
|
|
|
|
async def test_peer_cert(hass: HomeAssistant, tmp_path: Path) -> None:
|
|
"""Test required peer cert."""
|
|
cert_path, key_path, peer_cert_path = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch("ssl.SSLContext.load_verify_locations") as mock_load_verify_locations,
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
) as mock_context,
|
|
):
|
|
assert (
|
|
await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{
|
|
"http": {
|
|
"ssl_peer_certificate": peer_cert_path,
|
|
"ssl_profile": "modern",
|
|
"ssl_certificate": cert_path,
|
|
"ssl_key": key_path,
|
|
}
|
|
},
|
|
)
|
|
is True
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(mock_context.mock_calls) == 1
|
|
assert len(mock_load_verify_locations.mock_calls) == 1
|
|
|
|
|
|
def _stable_http_storage(
|
|
stable: dict, *, pending: dict | None = None, yaml_migration_done: bool = True
|
|
) -> dict:
|
|
"""Build a hass_storage entry seeded with a confirmed-working stable config.
|
|
|
|
``stable`` (and ``pending`` if given) are normalised through the storage
|
|
schema, matching what real users have on disk after migration / writes —
|
|
the load path does direct key access and assumes the payload is complete.
|
|
"""
|
|
normalised_stable = dict(HTTP_STORAGE_SCHEMA(stable))
|
|
normalised_pending = dict(HTTP_STORAGE_SCHEMA(pending)) if pending else None
|
|
return {
|
|
"version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": normalised_stable,
|
|
"pending": normalised_pending,
|
|
"yaml_migration_done": yaml_migration_done,
|
|
},
|
|
}
|
|
|
|
|
|
async def test_emergency_ssl_certificate_when_invalid(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test http starts with emergency self-signed cert on invalid cert."""
|
|
|
|
cert_path, key_path = await hass.async_add_executor_job(
|
|
_setup_broken_ssl_pem_files, tmp_path
|
|
)
|
|
# In recovery mode YAML is ignored, so seed the broken SSL paths into the
|
|
# store's stable slot — that's the only config recovery mode will look at.
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"ssl_certificate": str(cert_path), "ssl_key": str(key_path)}
|
|
)
|
|
hass.config.recovery_mode = True
|
|
assert await async_setup_component(hass, DOMAIN, {}) is True
|
|
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
assert (
|
|
"Home Assistant is running in recovery mode with an emergency"
|
|
" self signed ssl certificate because the configured SSL"
|
|
" certificate was not usable" in caplog.text
|
|
)
|
|
|
|
assert hass.http.site is not None
|
|
|
|
|
|
async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode(
|
|
hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Test an emergency cert is only used in recovery mode."""
|
|
|
|
cert_path, key_path = await hass.async_add_executor_job(
|
|
_setup_broken_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
assert (
|
|
await async_setup_component(
|
|
hass, DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
async def test_emergency_ssl_certificate_when_invalid_get_url_fails(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test http falls back to no ssl when emergency cert creation fails.
|
|
|
|
Ensure we can still start of we cannot determine the external url as well.
|
|
"""
|
|
cert_path, key_path = await hass.async_add_executor_job(
|
|
_setup_broken_ssl_pem_files, tmp_path
|
|
)
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"ssl_certificate": str(cert_path), "ssl_key": str(key_path)}
|
|
)
|
|
hass.config.recovery_mode = True
|
|
|
|
with patch(
|
|
"homeassistant.components.http.get_url", side_effect=NoURLAvailableError
|
|
) as mock_get_url:
|
|
assert await async_setup_component(hass, DOMAIN, {}) is True
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(mock_get_url.mock_calls) == 1
|
|
assert (
|
|
"Home Assistant is running in recovery mode with an emergency"
|
|
" self signed ssl certificate because the configured SSL"
|
|
" certificate was not usable" in caplog.text
|
|
)
|
|
|
|
assert hass.http.site is not None
|
|
|
|
|
|
async def test_invalid_ssl_and_cannot_create_emergency_cert(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test http falls back to no ssl on emergency cert creation failure."""
|
|
|
|
cert_path, key_path = await hass.async_add_executor_job(
|
|
_setup_broken_ssl_pem_files, tmp_path
|
|
)
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"ssl_certificate": str(cert_path), "ssl_key": str(key_path)}
|
|
)
|
|
hass.config.recovery_mode = True
|
|
|
|
with patch(
|
|
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
|
) as mock_builder:
|
|
assert await async_setup_component(hass, DOMAIN, {}) is True
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
|
assert len(mock_builder.mock_calls) == 1
|
|
|
|
assert hass.http.site is not None
|
|
|
|
|
|
async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test no-ssl fallback with peer cert when emergency cert fails.
|
|
|
|
When there is a peer cert verification and we cannot create
|
|
an emergency cert (probably will never happen since this means
|
|
the system is very broken), we do not want to startup http
|
|
as it would allow connections that are not verified by the cert.
|
|
"""
|
|
|
|
cert_path, key_path = await hass.async_add_executor_job(
|
|
_setup_broken_ssl_pem_files, tmp_path
|
|
)
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{
|
|
"ssl_certificate": str(cert_path),
|
|
"ssl_key": str(key_path),
|
|
"ssl_peer_certificate": str(cert_path),
|
|
}
|
|
)
|
|
hass.config.recovery_mode = True
|
|
|
|
with patch(
|
|
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
|
) as mock_builder:
|
|
assert await async_setup_component(hass, DOMAIN, {}) is False
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
|
assert len(mock_builder.mock_calls) == 1
|
|
|
|
|
|
async def test_cors_defaults(hass: HomeAssistant) -> None:
|
|
"""Test the CORS default settings."""
|
|
with patch("homeassistant.components.http.setup_cors") as mock_setup:
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert len(mock_setup.mock_calls) == 1
|
|
assert mock_setup.mock_calls[0][1][1] == ["https://cast.home-assistant.io"]
|
|
|
|
|
|
async def test_logging(
|
|
hass: HomeAssistant,
|
|
hass_client: ClientSessionGenerator,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Testing the access log works."""
|
|
await asyncio.gather(
|
|
*(
|
|
async_setup_component(hass, domain, {})
|
|
for domain in ("http", "logger", "api")
|
|
)
|
|
)
|
|
hass.states.async_set("logging.entity", "hello")
|
|
async with async_call_logger_set_level(
|
|
"aiohttp.access", "INFO", hass=hass, caplog=caplog
|
|
):
|
|
client = await hass_client()
|
|
response = await client.get("/api/states/logging.entity")
|
|
assert response.status == HTTPStatus.OK
|
|
|
|
assert "GET /api/states/logging.entity" in caplog.text
|
|
caplog.clear()
|
|
async with async_call_logger_set_level(
|
|
"aiohttp.access", "WARNING", hass=hass, caplog=caplog
|
|
):
|
|
response = await client.get("/api/states/logging.entity")
|
|
assert response.status == HTTPStatus.OK
|
|
assert "GET /api/states/logging.entity" not in caplog.text
|
|
|
|
|
|
async def test_ssl_issue_if_no_urls_configured(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
issue_registry: ir.IssueRegistry,
|
|
) -> None:
|
|
"""Test raising SSL issue if no external or internal URL is configured."""
|
|
|
|
assert hass.config.external_url is None
|
|
assert hass.config.internal_url is None
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
),
|
|
):
|
|
assert await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert ("http", "ssl_configured_without_configured_urls") in issue_registry.issues
|
|
|
|
|
|
async def test_ssl_issue_if_using_cloud(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
issue_registry: ir.IssueRegistry,
|
|
) -> None:
|
|
"""Test raising no SSL issue if not right configured but using cloud."""
|
|
assert hass.config.external_url is None
|
|
assert hass.config.internal_url is None
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch.object(cloud, "async_remote_ui_url", return_value="https://example.com"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
),
|
|
):
|
|
assert await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert (
|
|
"http",
|
|
"ssl_configured_without_configured_urls",
|
|
) not in issue_registry.issues
|
|
|
|
|
|
async def test_ssl_issue_if_not_connected_to_cloud(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
issue_registry: ir.IssueRegistry,
|
|
) -> None:
|
|
"""Test raising no SSL issue if not right configured and not connected to cloud."""
|
|
assert hass.config.external_url is None
|
|
assert hass.config.internal_url is None
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
),
|
|
patch(
|
|
"homeassistant.components.cloud.async_remote_ui_url",
|
|
side_effect=CloudNotAvailable,
|
|
),
|
|
):
|
|
assert await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert ("http", "ssl_configured_without_configured_urls") in issue_registry.issues
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("external_url", "internal_url"),
|
|
[
|
|
("https://example.com", "https://example.local"),
|
|
(None, "http://example.local"),
|
|
("https://example.com", None),
|
|
],
|
|
)
|
|
async def test_ssl_issue_urls_configured(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
issue_registry: ir.IssueRegistry,
|
|
external_url: str | None,
|
|
internal_url: str | None,
|
|
) -> None:
|
|
"""Test raising SSL issue if no external or internal URL is configured."""
|
|
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
|
|
hass.config.external_url = external_url
|
|
hass.config.internal_url = internal_url
|
|
|
|
with (
|
|
patch("ssl.SSLContext.load_cert_chain"),
|
|
patch(
|
|
"homeassistant.util.ssl.server_context_modern",
|
|
side_effect=server_context_modern,
|
|
),
|
|
):
|
|
assert await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert (
|
|
"http",
|
|
"ssl_configured_without_configured_urls",
|
|
) not in issue_registry.issues
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"hassio",
|
|
"http_config",
|
|
"expected_serverhost",
|
|
"expected_issues",
|
|
),
|
|
[
|
|
(False, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}),
|
|
(
|
|
False,
|
|
{"server_host": "0.0.0.0"},
|
|
["0.0.0.0"],
|
|
{("http", "deprecated_yaml")},
|
|
),
|
|
(True, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}),
|
|
(
|
|
True,
|
|
{"server_host": "0.0.0.0"},
|
|
[
|
|
"0.0.0.0",
|
|
],
|
|
{
|
|
("http", "server_host_deprecated_hassio"),
|
|
("http", "deprecated_yaml"),
|
|
},
|
|
),
|
|
],
|
|
)
|
|
async def test_server_host(
|
|
hass: HomeAssistant,
|
|
hassio: bool,
|
|
issue_registry: ir.IssueRegistry,
|
|
http_config: dict,
|
|
expected_serverhost: list,
|
|
expected_issues: set[tuple[str, str]],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test server_host behavior."""
|
|
mock_server = Mock()
|
|
with (
|
|
patch("homeassistant.components.http.is_hassio", return_value=hassio),
|
|
patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server,
|
|
):
|
|
assert await async_setup_component(
|
|
hass,
|
|
DOMAIN,
|
|
{"http": http_config},
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
mock_create_server.assert_called_once_with(
|
|
ANY,
|
|
expected_serverhost,
|
|
8123,
|
|
ssl=None,
|
|
backlog=128,
|
|
reuse_address=None,
|
|
reuse_port=None,
|
|
)
|
|
|
|
assert set(issue_registry.issues) == expected_issues
|
|
|
|
|
|
async def test_unix_socket_started_with_supervisor(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test unix socket is started when running under Supervisor."""
|
|
await hass.auth.async_create_system_user(
|
|
HASSIO_USER_NAME, group_ids=["system-admin"]
|
|
)
|
|
socket_path = tmp_path / "core.sock"
|
|
loop = asyncio.get_running_loop()
|
|
mock_sock = Mock()
|
|
with (
|
|
patch.dict(
|
|
os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False
|
|
),
|
|
patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
|
|
patch(
|
|
"homeassistant.components.http.web_runner.HomeAssistantUnixSite"
|
|
"._create_unix_socket",
|
|
return_value=mock_sock,
|
|
) as mock_create_sock,
|
|
patch.object(
|
|
loop, "create_unix_server", return_value=Mock()
|
|
) as mock_create_unix,
|
|
):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": {}})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
mock_create_sock.assert_called_once()
|
|
mock_create_unix.assert_called_once_with(ANY, sock=mock_sock, backlog=128)
|
|
assert hass.http.supervisor_site is not None
|
|
|
|
|
|
async def test_unix_socket_not_started_without_supervisor(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Test unix socket is not started when not running under Supervisor."""
|
|
with (
|
|
patch.dict(os.environ, {}, clear=False),
|
|
patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
|
|
):
|
|
os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None)
|
|
assert await async_setup_component(hass, DOMAIN, {"http": {}})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.http.supervisor_site is None
|
|
|
|
|
|
async def test_unix_socket_rejected_relative_path(
|
|
hass: HomeAssistant,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test unix socket is rejected when path is relative."""
|
|
with (
|
|
patch.dict(
|
|
os.environ,
|
|
{"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"},
|
|
clear=False,
|
|
),
|
|
patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
|
|
):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": {}})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.http.supervisor_site is None
|
|
assert "path must be absolute" in caplog.text
|
|
|
|
|
|
async def test_yaml_migration_to_storage(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test YAML config is migrated to the HTTP config store with a deprecation issue.
|
|
|
|
With no prior store, the migration stages YAML in ``pending`` (stable stays
|
|
on the schema defaults). The pending slot is what HA boots from until the
|
|
user confirms / promotes it via the UI.
|
|
"""
|
|
yaml_conf = {
|
|
"server_port": 9123,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/8"],
|
|
"ip_ban_enabled": False,
|
|
}
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml")
|
|
assert issue is not None
|
|
assert issue.severity is ir.IssueSeverity.WARNING
|
|
|
|
assert (
|
|
issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") is None
|
|
)
|
|
|
|
stored = hass_storage[DOMAIN]["data"]
|
|
assert stored["yaml_migration_done"] is True
|
|
assert stored["stable"]["server_port"] == 8123 # untouched defaults
|
|
pending = stored["pending"]
|
|
assert pending is not None
|
|
assert pending["server_port"] == 9123
|
|
assert pending["cors_allowed_origins"] == ["https://example.com"]
|
|
assert pending["trusted_proxies"] == ["127.0.0.0/8"]
|
|
assert pending["ip_ban_enabled"] is False
|
|
|
|
|
|
async def test_yaml_migration_matches_stable_no_pending(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""If the YAML matches the existing stable config, no pending should be created."""
|
|
existing_stable = {
|
|
"server_port": 9123,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/8"],
|
|
"ip_ban_enabled": False,
|
|
"login_attempts_threshold": -1,
|
|
"ssl_profile": "modern",
|
|
"use_x_frame_options": True,
|
|
}
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": existing_stable,
|
|
"pending": None,
|
|
"yaml_migration_done": False,
|
|
},
|
|
}
|
|
|
|
yaml_conf = {
|
|
"server_port": 9123,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/8"],
|
|
"ip_ban_enabled": False,
|
|
}
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
stored = hass_storage[DOMAIN]["data"]
|
|
assert stored["pending"] is None
|
|
assert stored["stable"] == existing_stable
|
|
|
|
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml")
|
|
assert issue is not None
|
|
|
|
|
|
async def test_yaml_migration_differs_from_stable_creates_pending(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""If the YAML differs from the existing stable config, it must be stored as pending."""
|
|
existing_stable = {
|
|
"server_port": 9123,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"login_attempts_threshold": -1,
|
|
"ip_ban_enabled": True,
|
|
"ssl_profile": "modern",
|
|
"use_x_frame_options": True,
|
|
}
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": existing_stable,
|
|
"pending": None,
|
|
"yaml_migration_done": False,
|
|
},
|
|
}
|
|
|
|
yaml_conf = {"server_port": 8765, "ip_ban_enabled": False}
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
stored = hass_storage[DOMAIN]["data"]
|
|
assert stored["stable"] == existing_stable
|
|
assert stored["pending"] == {
|
|
"server_port": 8765,
|
|
"cors_allowed_origins": ["https://cast.home-assistant.io"],
|
|
"login_attempts_threshold": -1,
|
|
"ip_ban_enabled": False,
|
|
"ssl_profile": "modern",
|
|
"use_x_frame_options": True,
|
|
}
|
|
|
|
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml")
|
|
assert issue is not None
|
|
|
|
|
|
async def test_yaml_migration_failure_creates_error_issue(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
) -> None:
|
|
"""Test that an error during YAML migration creates an error issue."""
|
|
yaml_conf = {"server_port": 9123}
|
|
|
|
with (
|
|
patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
|
|
patch(
|
|
"homeassistant.components.http.config.HTTPConfigStore.async_migrate_yaml",
|
|
side_effect=RuntimeError("boom"),
|
|
),
|
|
):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error")
|
|
assert issue is not None
|
|
assert issue.severity is ir.IssueSeverity.ERROR
|
|
|
|
assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None
|
|
|
|
|
|
async def test_yaml_still_present_after_migration_creates_issue(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""When YAML lingers after migration, a repair issue is surfaced and YAML is ignored."""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876}, yaml_migration_done=True
|
|
)
|
|
|
|
yaml_conf = {"server_port": 1234}
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
# YAML must be ignored once migration is done; stable wins.
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 9876
|
|
|
|
issue = issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration")
|
|
assert issue is not None
|
|
assert issue.severity is ir.IssueSeverity.WARNING
|
|
|
|
|
|
async def test_yaml_still_present_issue_cleared_when_yaml_removed(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""A previously created leftover-YAML issue is cleared once YAML is removed."""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876}, yaml_migration_done=True
|
|
)
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
"yaml_still_present_after_migration",
|
|
is_fixable=False,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key="yaml_still_present_after_migration",
|
|
)
|
|
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert (
|
|
issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration")
|
|
is None
|
|
)
|
|
|
|
|
|
async def test_setup_uses_stable_config_when_no_yaml(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test HTTP config is loaded from the stable slot when no YAML or pending is set."""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{
|
|
"server_port": 9876,
|
|
"cors_allowed_origins": ["https://stored.example.com"],
|
|
}
|
|
)
|
|
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 9876
|
|
|
|
assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None
|
|
assert (
|
|
issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") is None
|
|
)
|
|
|
|
|
|
async def test_setup_prefers_pending_over_stable_in_normal_mode(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Pending overrides stable on a normal boot so the new config gets tested."""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876}, pending={"server_port": 9999}
|
|
)
|
|
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 9999
|
|
|
|
|
|
async def test_recovery_mode_falls_back_to_stable(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""In recovery mode the pending config is ignored to keep HA reachable."""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876}, pending={"server_port": 9999}
|
|
)
|
|
hass.config.recovery_mode = True
|
|
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 9876
|
|
|
|
|
|
async def test_recovery_mode_with_no_storage(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
issue_registry: ir.IssueRegistry,
|
|
) -> None:
|
|
"""Recovery mode with no prior storage starts HTTP on the schema defaults.
|
|
|
|
This covers the first-ever-boot-into-recovery-mode case: bootstrap fell
|
|
through to recovery before HTTP ever had a chance to migrate YAML, so the
|
|
store is empty and we must come up cleanly on defaults.
|
|
"""
|
|
assert "http" not in hass_storage
|
|
hass.config.recovery_mode = True
|
|
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 8123
|
|
# Recovery mode must not trigger YAML migration side effects.
|
|
assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None
|
|
|
|
|
|
async def test_recovery_mode_ignores_yaml(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
issue_registry: ir.IssueRegistry,
|
|
) -> None:
|
|
"""YAML config must not be applied or migrated while in recovery mode.
|
|
|
|
The whole point of recovery mode is to ignore the user's (possibly bad)
|
|
config and fall back to the last known good ``stable`` slot. Migrating
|
|
YAML here would defeat that and could re-introduce the broken config.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 5555}, yaml_migration_done=False
|
|
)
|
|
hass.config.recovery_mode = True
|
|
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(
|
|
hass, DOMAIN, {"http": {"server_port": 1234}}
|
|
)
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
args, _ = mock_create_server.call_args
|
|
# YAML's port must NOT win: stable is the only source of truth in recovery.
|
|
assert args[2] == 5555
|
|
# The migration must not run in recovery mode, so its flag stays untouched
|
|
# and no deprecation issue is created on this boot.
|
|
assert hass_storage[DOMAIN]["data"]["yaml_migration_done"] is False
|
|
assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None
|
|
|
|
|
|
async def test_setup_migrates_v1_storage_to_v2(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""An existing v1 store is migrated into the stable slot."""
|
|
hass_storage[DOMAIN] = {
|
|
"version": 1,
|
|
"key": "http",
|
|
"data": {"server_port": 9876},
|
|
}
|
|
|
|
mock_server = Mock()
|
|
with patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server:
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
# The migrated v1 store config is only used in recovery mode. Since this
|
|
# test isn't running in recovery mode, the YAML migration runs on first
|
|
# boot after store migration. With no YAML http config, the default config is migrated to the pending slot and used. Therefore we assert below the default port (8123)
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 8123
|
|
assert hass_storage[DOMAIN]["version"] == 2
|
|
data = hass_storage[DOMAIN]["data"]
|
|
# The v1→v2 migration normalises the payload through the storage schema,
|
|
# so the v2 stable slot is well-formed (all keys present) on disk.
|
|
assert data["stable"]["server_port"] == 9876
|
|
assert data["stable"]["ip_ban_enabled"] is True
|
|
assert data["pending"] == _DEFAULT_CONFIG
|
|
assert data["yaml_migration_done"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env", "expected_port"),
|
|
[
|
|
pytest.param({}, 8123, id="unset"),
|
|
pytest.param({ENV_SETUP_PORT: "80"}, 80, id="valid"),
|
|
pytest.param({ENV_SETUP_PORT: "0"}, 8123, id="out-of-range"),
|
|
pytest.param({ENV_SETUP_PORT: "notaport"}, 8123, id="not-a-number"),
|
|
pytest.param({ENV_SETUP_PORT: ""}, 8123, id="empty"),
|
|
],
|
|
)
|
|
def test_default_server_port(
|
|
env: dict[str, str],
|
|
expected_port: int,
|
|
) -> None:
|
|
"""Test SETUP_PORT overrides the default port and invalid values fall back."""
|
|
with patch.dict(os.environ, env, clear=True):
|
|
assert default_server_port() == expected_port
|
|
|
|
|
|
async def test_setup_port_env_var_used_as_default(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test SETUP_PORT is used as the default server port without YAML config."""
|
|
mock_server = Mock()
|
|
with (
|
|
patch.dict(os.environ, {ENV_SETUP_PORT: "80"}),
|
|
patch(
|
|
"asyncio.BaseEventLoop.create_server", return_value=mock_server
|
|
) as mock_create_server,
|
|
):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
args, _ = mock_create_server.call_args
|
|
assert args[2] == 80
|
|
assert hass_storage["http"]["data"]["pending"]["server_port"] == 80
|
|
|
|
|
|
async def test_websocket_http_config(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the http/config, configure and promote websocket commands."""
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
|
|
# Staging a new config triggers a restart so the pending config is applied.
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
# On a fresh setup the stable slot is seeded with the schema defaults and
|
|
# there is no pending config.
|
|
await ws_client.send_json_auto_id({"type": "http/config"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {
|
|
"stable": _DEFAULT_CONFIG,
|
|
"pending": None,
|
|
"revert_at": None,
|
|
}
|
|
|
|
new_config = {
|
|
"server_port": 9123,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/8"],
|
|
"ip_ban_enabled": False,
|
|
"login_attempts_threshold": 5,
|
|
"ssl_profile": "modern",
|
|
"use_x_frame_options": True,
|
|
}
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": new_config}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
pending = hass_storage["http"]["data"]["pending"]
|
|
assert pending["server_port"] == 9123
|
|
assert pending["trusted_proxies"] == ["127.0.0.0/8"]
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 1
|
|
|
|
# Stable is unchanged until the user promotes, but the pending config is
|
|
# now returned alongside it.
|
|
await ws_client.send_json_auto_id({"type": "http/config"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {
|
|
"stable": _DEFAULT_CONFIG,
|
|
"pending": new_config,
|
|
"revert_at": None,
|
|
}
|
|
|
|
# Promote: pending becomes stable, pending is cleared.
|
|
await ws_client.send_json_auto_id({"type": "http/config/promote"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert hass_storage["http"]["data"]["pending"] is None
|
|
assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123
|
|
|
|
await ws_client.send_json_auto_id({"type": "http/config"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {
|
|
"stable": new_config,
|
|
"pending": None,
|
|
"revert_at": None,
|
|
}
|
|
|
|
# Promoting again with no pending is rejected.
|
|
await ws_client.send_json_auto_id({"type": "http/config/promote"})
|
|
response = await ws_client.receive_json()
|
|
assert not response["success"]
|
|
assert response["error"]["code"] == "not_allowed"
|
|
|
|
# Staging a different config again changes the pending slot -> restart.
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": {"server_port": 7000}}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
assert hass_storage["http"]["data"]["pending"]["server_port"] == 7000
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 2
|
|
|
|
# Clearing a previously staged config also changes the active config back
|
|
# to stable, so it must trigger a restart too.
|
|
await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
assert hass_storage["http"]["data"]["pending"] is None
|
|
assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 3
|
|
|
|
# Clearing again when there is no pending config is a no-op -> no restart.
|
|
await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": False}
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 3
|
|
|
|
|
|
async def test_pending_config_auto_reverts_to_stable(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A loaded pending config reverts to stable if it is not confirmed in time."""
|
|
hass_storage["http"] = _stable_http_storage(
|
|
{"server_port": 9876}, pending={"server_port": 9999}
|
|
)
|
|
|
|
# A revert clears the pending config and restarts to apply stable.
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
# The revert deadline is anchored to the (frozen) load time.
|
|
revert_at = dt_util.utcnow() + AUTO_REVERT_DELAY
|
|
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
|
|
# While the unconfirmed pending config is active, a revert deadline is
|
|
# returned alongside it.
|
|
await ws_client.send_json_auto_id({"type": "http/config"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {
|
|
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}),
|
|
"pending": HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
|
"revert_at": revert_at.isoformat(),
|
|
}
|
|
|
|
# After the delay elapses without a promotion, pending is dropped and a
|
|
# restart is requested so the stable config is applied.
|
|
freezer.tick(AUTO_REVERT_DELAY)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass_storage["http"]["data"] == {
|
|
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}),
|
|
"pending": None,
|
|
"yaml_migration_done": True,
|
|
}
|
|
assert len(restart_calls) == 1
|
|
|
|
|
|
async def test_pending_config_promote_cancels_revert(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Promoting a pending config cancels the scheduled revert."""
|
|
hass_storage["http"] = _stable_http_storage(
|
|
{"server_port": 9876}, pending={"server_port": 9999}
|
|
)
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
|
|
# Confirm the pending config before the revert fires.
|
|
await ws_client.send_json_auto_id({"type": "http/config/promote"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
|
|
# The deadline is cleared once the config is confirmed.
|
|
await ws_client.send_json_auto_id({"type": "http/config"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {
|
|
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
|
"pending": None,
|
|
"revert_at": None,
|
|
}
|
|
|
|
# The cancelled revert must not fire after the delay.
|
|
freezer.tick(AUTO_REVERT_DELAY)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass_storage["http"]["data"] == {
|
|
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
|
"pending": None,
|
|
"yaml_migration_done": True,
|
|
}
|
|
assert len(restart_calls) == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"config",
|
|
[
|
|
{"server_port": "not-a-port"},
|
|
{
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["not-an-ip-network"],
|
|
},
|
|
],
|
|
)
|
|
async def test_websocket_http_config_invalid(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
config: dict,
|
|
) -> None:
|
|
"""Test that an invalid HTTP config is rejected."""
|
|
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": config}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert not response["success"]
|
|
assert response["error"]["code"] == "invalid_format"
|