mirror of
https://github.com/home-assistant/core.git
synced 2026-08-06 21:35:13 +01:00
3005 lines
101 KiB
Python
3005 lines
101 KiB
Python
"""The tests for the Home Assistant HTTP component."""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable, Generator, Iterator
|
|
from contextlib import contextmanager
|
|
import errno
|
|
from http import HTTPStatus
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import ssl
|
|
from typing import Any
|
|
from unittest.mock import ANY, AsyncMock, Mock, patch
|
|
|
|
import aiohttp
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
import pytest
|
|
|
|
from homeassistant.auth.providers.homeassistant import HassAuthProvider
|
|
from homeassistant.components import cloud, http, onboarding
|
|
from homeassistant.components.cloud import CloudNotAvailable
|
|
from homeassistant.components.http import DOMAIN
|
|
from homeassistant.components.http.config import (
|
|
_DEFAULT_CONFIG,
|
|
AUTO_REVERT_DELAY,
|
|
ERROR_APPLY_FAILED,
|
|
ERROR_NOT_PROMOTED,
|
|
HTTP_STORAGE_SCHEMA,
|
|
ActiveConfigType,
|
|
async_get_and_load_store,
|
|
default_server_port,
|
|
)
|
|
from homeassistant.components.http.const import ENV_SETUP_PORT, ENV_SUPERVISOR
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME
|
|
from homeassistant.core import CoreState, HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
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
|
|
|
|
|
|
# The unpatched original, for tests that exercise the real implementation.
|
|
_REAL_CREATE_SERVER = http.HomeAssistantHTTP._async_create_server
|
|
|
|
|
|
async def _ephemeral_server(hass: HomeAssistant) -> asyncio.Server:
|
|
"""Create a bound but not serving server on an ephemeral localhost port."""
|
|
return await hass.loop.create_server(
|
|
asyncio.Protocol, "127.0.0.1", 0, start_serving=False
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_create_server() -> Generator[Mock]:
|
|
"""Bind an ephemeral localhost server instead of the configured address.
|
|
|
|
Binding the configured address for real would make parallel tests collide
|
|
on ports; an ephemeral localhost server keeps the serving path real.
|
|
"""
|
|
servers: list[asyncio.Server] = []
|
|
|
|
async def _bind_ephemeral(self: http.HomeAssistantHTTP) -> asyncio.Server:
|
|
server = await self.hass.loop.create_server(
|
|
self._make_protocol,
|
|
"127.0.0.1",
|
|
0,
|
|
ssl=self.context,
|
|
start_serving=False,
|
|
)
|
|
servers.append(server)
|
|
return server
|
|
|
|
with patch(
|
|
"homeassistant.components.http.HomeAssistantHTTP._async_create_server",
|
|
autospec=True,
|
|
side_effect=_bind_ephemeral,
|
|
) as mock_create:
|
|
yield mock_create
|
|
|
|
# Close any server that is not already closed (closing twice is a no-op).
|
|
for server in servers:
|
|
server.close()
|
|
|
|
|
|
async def _bind_redirect_ephemeral(self: http.HomeAssistantHTTP) -> asyncio.Server:
|
|
"""Bind the legacy redirect on an ephemeral localhost port instead of 8123."""
|
|
assert self._legacy_redirect_runner is not None
|
|
return await self.hass.loop.create_server(
|
|
self._legacy_redirect_runner.server, "127.0.0.1", 0, start_serving=False
|
|
)
|
|
|
|
|
|
# A port-80 default config, standing in for _DEFAULT_CONFIG under Supervisor
|
|
# (which is frozen at import to port 8123 in the test process).
|
|
DEFAULT_80_CONFIG = HTTP_STORAGE_SCHEMA({"server_port": 80})
|
|
|
|
|
|
@contextmanager
|
|
def _supervisor_default_config() -> Iterator[None]:
|
|
"""Simulate a fresh Supervisor install: the default config on port 80.
|
|
|
|
_DEFAULT_CONFIG is frozen at import (port 8123 without Supervisor in the
|
|
test process), so both references are patched to a port-80 default to
|
|
reproduce production, where SUPERVISOR is set before the module is
|
|
imported. The redirect binds an ephemeral localhost port instead of 8123.
|
|
"""
|
|
with (
|
|
# clear=True so a developer/CI SETUP_PORT can't skew the simulation.
|
|
patch.dict(os.environ, {ENV_SUPERVISOR: "core"}, clear=True),
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server.HomeAssistantHTTP._async_create_redirect_server",
|
|
autospec=True,
|
|
side_effect=_bind_redirect_ephemeral,
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
async def _setup_http_with_onboarding(
|
|
hass: HomeAssistant, *, onboarded: bool = False
|
|
) -> None:
|
|
"""Set up http and onboarding, then start Home Assistant.
|
|
|
|
The legacy-port transition is bound to onboarding, so onboarding must be
|
|
set up (as it always is via frontend in production) for the redirect to
|
|
start.
|
|
"""
|
|
assert await async_setup_component(hass, "http", {})
|
|
assert await async_setup_component(hass, "onboarding", {})
|
|
if onboarded:
|
|
hass.data[onboarding.DOMAIN].onboarded = True
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
|
|
def _complete_onboarding(hass: HomeAssistant) -> None:
|
|
"""Mark onboarding complete and fire its listeners, as the views do."""
|
|
data = hass.data[onboarding.DOMAIN]
|
|
data.onboarded = True
|
|
for listener in list(data.listeners):
|
|
listener()
|
|
|
|
|
|
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
|
|
|
|
|
|
# Fixed slot-creation times for seeded configs.
|
|
STABLE_CREATED_AT = "2026-06-01T00:00:00+00:00"
|
|
PENDING_CREATED_AT = "2026-07-01T00:00:00+00:00"
|
|
|
|
|
|
def _stored_config(
|
|
config: dict,
|
|
*,
|
|
created_at: str = PENDING_CREATED_AT,
|
|
error: str | None = None,
|
|
error_message: str | None = None,
|
|
) -> dict:
|
|
"""Return a schema-normalised config slot as persisted at storage version 2.2."""
|
|
return {
|
|
**HTTP_STORAGE_SCHEMA(config),
|
|
"created_at": created_at,
|
|
"error": error,
|
|
"error_message": error_message,
|
|
}
|
|
|
|
|
|
def _stable_http_storage(
|
|
stable: dict,
|
|
*,
|
|
pending: dict | None = None,
|
|
pending_error: str | 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 and carry the created_at/error metadata, matching what real users
|
|
have on disk after migration / writes — the load path does direct key
|
|
access and assumes the payload is complete. A ``pending_error`` seeds the
|
|
pending config as already failed/reverted.
|
|
"""
|
|
return {
|
|
"version": 2,
|
|
"minor_version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": _stored_config(stable, created_at=STABLE_CREATED_AT),
|
|
"pending": (
|
|
_stored_config(pending, error=pending_error) if pending else None
|
|
),
|
|
"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._server is not None
|
|
|
|
|
|
async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test an emergency cert is only used in recovery mode.
|
|
|
|
A broken SSL config in the stable slot fails setup (activating recovery
|
|
mode on a real boot); only recovery mode uses the emergency certificate.
|
|
"""
|
|
|
|
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)}
|
|
)
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {}) 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.server.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._server 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.server.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._server 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.
|
|
This intentionally overrides the recovery-mode fallback to the default
|
|
config: connections must never be accepted without client certificate
|
|
verification once it is configured.
|
|
"""
|
|
|
|
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.server.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_emergency_ssl_certificate_enforces_peer_certificate(
|
|
hass: HomeAssistant,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the emergency cert still enforces client certificate verification.
|
|
|
|
When the configured SSL certificate is broken and recovery mode falls
|
|
back to the emergency self-signed certificate, a configured peer
|
|
certificate must still be applied - connections must never be accepted
|
|
without client certificate verification once it is configured.
|
|
"""
|
|
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("ssl.SSLContext.load_verify_locations") as mock_load_verify:
|
|
assert await async_setup_component(hass, DOMAIN, {}) is True
|
|
|
|
assert "emergency self signed ssl certificate" in caplog.text
|
|
mock_load_verify.assert_called_once_with(str(cert_path))
|
|
assert hass.http.context is not None
|
|
assert hass.http.context.verify_mode is ssl.CERT_REQUIRED
|
|
|
|
|
|
async def test_create_server_passes_configuration(hass: HomeAssistant) -> None:
|
|
"""The real server factory passes the configured values to asyncio."""
|
|
server = http.HomeAssistantHTTP(
|
|
hass,
|
|
server_host=["127.0.0.1", "::1"],
|
|
server_port=1234,
|
|
ssl_certificate=None,
|
|
ssl_peer_certificate=None,
|
|
ssl_key=None,
|
|
trusted_proxies=[],
|
|
ssl_profile=http.SSL_MODERN,
|
|
)
|
|
|
|
with patch.object(
|
|
hass.loop, "create_server", new=AsyncMock(return_value=Mock())
|
|
) as mock_create:
|
|
await _REAL_CREATE_SERVER(server)
|
|
|
|
mock_create.assert_called_once_with(
|
|
server._make_protocol,
|
|
["127.0.0.1", "::1"],
|
|
1234,
|
|
ssl=None,
|
|
backlog=128,
|
|
start_serving=False,
|
|
)
|
|
|
|
|
|
async def test_cors_defaults(hass: HomeAssistant) -> None:
|
|
"""Test the CORS default settings."""
|
|
with patch("homeassistant.components.http.server.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(
|
|
("http_config", "expected_serverhost"),
|
|
[
|
|
pytest.param({}, ["0.0.0.0", "::"], id="default"),
|
|
pytest.param({"server_host": "0.0.0.0"}, ["0.0.0.0"], id="server_host"),
|
|
],
|
|
)
|
|
async def test_server_host(
|
|
hass: HomeAssistant,
|
|
issue_registry: ir.IssueRegistry,
|
|
http_config: dict,
|
|
expected_serverhost: list,
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""Test server_host behavior."""
|
|
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()
|
|
assert hass.http.server_host == expected_serverhost
|
|
assert hass.http.server_port == 8123
|
|
|
|
assert set(issue_registry.issues) == {("http", "deprecated_yaml")}
|
|
|
|
|
|
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(
|
|
"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),
|
|
):
|
|
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,
|
|
),
|
|
):
|
|
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_supervisor_http_config_view(
|
|
hass: HomeAssistant,
|
|
hass_client: ClientSessionGenerator,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Test the HTTP config view is registered and served over the socket only."""
|
|
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()
|
|
with (
|
|
patch.dict(
|
|
os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.web_runner.HomeAssistantUnixSite"
|
|
"._create_unix_socket",
|
|
return_value=Mock(),
|
|
),
|
|
patch.object(loop, "create_unix_server", return_value=Mock()),
|
|
):
|
|
assert await async_setup_component(hass, DOMAIN, {"http": {}})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
client = await hass_client()
|
|
|
|
# Hidden from ordinary (non-socket) requests.
|
|
resp = await client.get("/api/core/http_config")
|
|
assert resp.status == HTTPStatus.NOT_FOUND
|
|
|
|
with patch(
|
|
"homeassistant.components.http.server.is_supervisor_unix_socket_request",
|
|
return_value=True,
|
|
):
|
|
# Served over the Supervisor Unix socket with the live server config.
|
|
resp = await client.get("/api/core/http_config")
|
|
assert resp.status == HTTPStatus.OK
|
|
assert await resp.json() == {
|
|
"port": 8123,
|
|
"ssl": False,
|
|
"ssl_peer_certificate": False,
|
|
"server_host": ["0.0.0.0", "::"],
|
|
}
|
|
|
|
# A specific bind is reported verbatim so Supervisor can decide.
|
|
hass.http.server_host = ["1.2.3.4"]
|
|
resp = await client.get("/api/core/http_config")
|
|
assert resp.status == HTTPStatus.OK
|
|
assert (await resp.json())["server_host"] == ["1.2.3.4"]
|
|
|
|
# TLS is reported from the active SSL context and peer certificate.
|
|
hass.http.context = Mock(spec=ssl.SSLContext)
|
|
hass.http.ssl_peer_certificate = "/config/peer.pem"
|
|
resp = await client.get("/api/core/http_config")
|
|
assert resp.status == HTTPStatus.OK
|
|
result = await resp.json()
|
|
assert result["ssl"] is True
|
|
assert result["ssl_peer_certificate"] is True
|
|
|
|
# Recovery mode can leave a certificate configured while the context
|
|
# failed to build and the listener runs plaintext: report plaintext.
|
|
hass.http.ssl_certificate = "/config/cert.pem"
|
|
hass.http.context = None
|
|
resp = await client.get("/api/core/http_config")
|
|
assert resp.status == HTTPStatus.OK
|
|
assert (await resp.json())["ssl"] is False
|
|
|
|
|
|
@pytest.mark.usefixtures("freezer")
|
|
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,
|
|
}
|
|
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"] == _DEFAULT_CONFIG # untouched defaults
|
|
assert stored["pending"] == _stored_config(
|
|
yaml_conf, created_at=dt_util.utcnow().isoformat()
|
|
)
|
|
|
|
|
|
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,
|
|
}
|
|
stored_stable = {
|
|
**existing_stable,
|
|
"created_at": STABLE_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"minor_version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": stored_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,
|
|
}
|
|
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"] == stored_stable
|
|
|
|
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml")
|
|
assert issue is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("yaml_extra", "expected_stable_proxies", "expected_pending"),
|
|
[
|
|
pytest.param(
|
|
{},
|
|
["127.0.0.0/8", "10.11.12.0/24"],
|
|
None,
|
|
id="only-lost-masks-repairs-stable",
|
|
),
|
|
pytest.param(
|
|
{"server_port": 8765},
|
|
["127.0.0.0/32", "10.11.12.0/32"],
|
|
{
|
|
"server_port": 8765,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/8", "10.11.12.0/24"],
|
|
"ip_ban_enabled": False,
|
|
"login_attempts_threshold": -1,
|
|
"ssl_profile": "modern",
|
|
"use_x_frame_options": True,
|
|
},
|
|
id="other-change-creates-pending",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("freezer")
|
|
async def test_yaml_migration_stable_lost_trusted_proxy_masks(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
yaml_extra: dict[str, Any],
|
|
expected_stable_proxies: list[str],
|
|
expected_pending: dict[str, Any] | None,
|
|
) -> None:
|
|
"""Test YAML migration against a stable config with lost trusted proxy masks.
|
|
|
|
Releases up to 2026.7.1 stored trusted proxies without the network mask,
|
|
which the v1->v2 store migration turned into host networks. If the YAML
|
|
config only differs from stable by those lost masks, the masks must be
|
|
restored in stable instead of staging the unchanged YAML as pending.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{
|
|
"server_port": 9123,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
"use_x_forwarded_for": True,
|
|
"trusted_proxies": ["127.0.0.0/32", "10.11.12.0/32"],
|
|
"ip_ban_enabled": False,
|
|
},
|
|
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", "10.11.12.0/24"],
|
|
"ip_ban_enabled": False,
|
|
**yaml_extra,
|
|
}
|
|
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["yaml_migration_done"] is True
|
|
assert stored["pending"] == (
|
|
_stored_config(expected_pending, created_at=dt_util.utcnow().isoformat())
|
|
if expected_pending is not None
|
|
else None
|
|
)
|
|
assert stored["stable"]["trusted_proxies"] == expected_stable_proxies
|
|
assert stored["stable"]["created_at"] == STABLE_CREATED_AT
|
|
|
|
|
|
@pytest.mark.usefixtures("freezer")
|
|
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,
|
|
}
|
|
stored_stable = {
|
|
**existing_stable,
|
|
"created_at": STABLE_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"minor_version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": stored_stable,
|
|
"pending": None,
|
|
"yaml_migration_done": False,
|
|
},
|
|
}
|
|
|
|
yaml_conf = {"server_port": 8765, "ip_ban_enabled": False}
|
|
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"] == stored_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,
|
|
"created_at": dt_util.utcnow().isoformat(),
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
|
|
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(
|
|
"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}
|
|
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.
|
|
assert hass.config.api.port == 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",
|
|
)
|
|
|
|
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"],
|
|
}
|
|
)
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 9876
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.STABLE
|
|
|
|
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}
|
|
)
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 9999
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.PENDING
|
|
|
|
|
|
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
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 9876
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.STABLE
|
|
|
|
|
|
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
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 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
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {"http": {"server_port": 1234}})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
# YAML's port must NOT win: stable is the only source of truth in recovery.
|
|
assert hass.config.api.port == 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
|
|
|
|
|
|
@pytest.mark.usefixtures("freezer")
|
|
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},
|
|
}
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
# With no YAML http config there is nothing to migrate: the stable slot
|
|
# from the v1 store stays the source of truth and no pending trial is
|
|
# staged.
|
|
assert hass.config.api.port == 9876
|
|
assert hass_storage[DOMAIN]["version"] == 2
|
|
assert hass_storage[DOMAIN]["minor_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"] == _stored_config(
|
|
{"server_port": 9876}, created_at=dt_util.utcnow().isoformat()
|
|
)
|
|
assert data["pending"] is None
|
|
assert data["yaml_migration_done"] is True
|
|
|
|
|
|
async def test_upgrade_with_stored_old_default_config_keeps_port(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""A stored old default config keeps its port under the new Supervisor default.
|
|
|
|
Releases up to 2026.7 persisted the fully expanded config — including the
|
|
explicit default server_port 8123 — to the v1 store on every boot. When
|
|
such an install is upgraded under Supervisor (where the default port is
|
|
now 80), the new default must not be staged as a pending trial: nothing
|
|
promotes it, so the trial would auto-revert with a restart five minutes
|
|
later and flip the port back to 8123.
|
|
"""
|
|
hass_storage[DOMAIN] = {
|
|
"version": 1,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"server_port": 8123,
|
|
"cors_allowed_origins": ["https://cast.home-assistant.io"],
|
|
"use_x_frame_options": True,
|
|
"ip_ban_enabled": True,
|
|
"login_attempts_threshold": -1,
|
|
"ssl_profile": "modern",
|
|
},
|
|
}
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
with _supervisor_default_config():
|
|
await _setup_http_with_onboarding(hass)
|
|
|
|
assert hass.config.api.port == 8123
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.STABLE
|
|
assert store.revert_deadline is None
|
|
data = hass_storage[DOMAIN]["data"]
|
|
assert data["pending"] is None
|
|
assert data["stable"]["server_port"] == 8123
|
|
assert data["yaml_migration_done"] is True
|
|
# The stored config differs from the new default config, so the
|
|
# legacy-port transition does not apply.
|
|
assert hass.http._legacy_redirect_server is None
|
|
|
|
# Nothing was staged for trial: no auto-revert restart fires.
|
|
freezer.tick(AUTO_REVERT_DELAY)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 0
|
|
|
|
|
|
@pytest.mark.usefixtures("freezer")
|
|
async def test_setup_migrates_v2_1_storage_to_v2_2(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""v2.1 config slots gain the created_at/error metadata; pending stays armed."""
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"minor_version": 1,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})),
|
|
"pending": dict(HTTP_STORAGE_SCHEMA({"server_port": 9999})),
|
|
"yaml_migration_done": True,
|
|
},
|
|
}
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 9999
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.PENDING
|
|
assert store.pending == {
|
|
**HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
|
"created_at": dt_util.utcnow().isoformat(),
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
assert store.stable == {
|
|
**HTTP_STORAGE_SCHEMA({"server_port": 9876}),
|
|
"created_at": dt_util.utcnow().isoformat(),
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
# The migrated payload is written back to disk right away.
|
|
assert hass_storage[DOMAIN]["minor_version"] == 2
|
|
assert hass_storage[DOMAIN]["data"] == {
|
|
"stable": _stored_config(
|
|
{"server_port": 9876}, created_at=dt_util.utcnow().isoformat()
|
|
),
|
|
"pending": _stored_config(
|
|
{"server_port": 9999}, created_at=dt_util.utcnow().isoformat()
|
|
),
|
|
"yaml_migration_done": 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"),
|
|
pytest.param({ENV_SUPERVISOR: "core"}, 80, id="supervisor"),
|
|
pytest.param(
|
|
{ENV_SUPERVISOR: "core", ENV_SETUP_PORT: "8080"},
|
|
8080,
|
|
id="supervisor-setup-port-override",
|
|
),
|
|
pytest.param(
|
|
{ENV_SUPERVISOR: "core", ENV_SETUP_PORT: "notaport"},
|
|
80,
|
|
id="supervisor-invalid-setup-port",
|
|
),
|
|
],
|
|
)
|
|
def test_default_server_port(
|
|
env: dict[str, str],
|
|
expected_port: int,
|
|
) -> None:
|
|
"""Test the default port, including Supervisor and SETUP_PORT overrides."""
|
|
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.
|
|
|
|
In production SETUP_PORT is set before the process starts, so it is baked
|
|
into _DEFAULT_CONFIG at import; the constant is patched to reproduce that.
|
|
A fresh install serves the default config from the stable slot directly,
|
|
without staging it as a pending trial.
|
|
"""
|
|
with (
|
|
patch.dict(os.environ, {ENV_SETUP_PORT: "80"}),
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 80
|
|
assert hass_storage["http"]["data"]["pending"] is None
|
|
assert hass_storage["http"]["data"]["stable"]["server_port"] == 80
|
|
|
|
|
|
async def test_default_config_under_supervisor_starts_redirect(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the default config under Supervisor uses port 80 and redirects 8123."""
|
|
with _supervisor_default_config():
|
|
await _setup_http_with_onboarding(hass)
|
|
|
|
assert hass.config.api.port == 80
|
|
assert hass.http._legacy_redirect_server is not None
|
|
|
|
|
|
async def test_persisted_default_config_starts_redirect(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the redirect still starts for a default config loaded from disk.
|
|
|
|
A persisted default config carries created_at/error metadata, so the
|
|
transition gate must compare without it (not by identity or raw equality).
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80})
|
|
|
|
with _supervisor_default_config():
|
|
await _setup_http_with_onboarding(hass)
|
|
|
|
assert hass.config.api.port == 80
|
|
assert hass.http._legacy_redirect_server is not None
|
|
|
|
|
|
async def test_no_redirect_when_already_onboarded(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the redirect is not started when onboarding is already complete."""
|
|
with _supervisor_default_config():
|
|
await _setup_http_with_onboarding(hass, onboarded=True)
|
|
|
|
assert hass.http._legacy_redirect_server is None
|
|
|
|
|
|
async def test_redirect_stops_on_onboarding_complete(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the redirect is torn down once onboarding completes."""
|
|
with _supervisor_default_config():
|
|
await _setup_http_with_onboarding(hass)
|
|
|
|
server = hass.http
|
|
assert server._legacy_redirect_server is not None
|
|
|
|
_complete_onboarding(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert server._legacy_redirect_server is None
|
|
assert server._legacy_redirect_runner is None
|
|
|
|
|
|
async def test_no_redirect_without_supervisor(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test no redirect is started for the default config outside Supervisor."""
|
|
assert await async_setup_component(hass, "http", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 8123
|
|
assert hass.http._legacy_redirect_runner is None
|
|
assert hass.http._legacy_redirect_server is None
|
|
|
|
|
|
async def test_no_redirect_with_setup_port_outside_supervisor(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test SETUP_PORT alone (no Supervisor) does not start the redirect.
|
|
|
|
SETUP_PORT changes the default port for plain container installs too, but
|
|
the transition is Supervisor-only.
|
|
"""
|
|
with (
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
):
|
|
assert await async_setup_component(hass, "http", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.http._legacy_redirect_server is None
|
|
|
|
|
|
async def test_no_redirect_when_http_configured(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test no redirect is started once HTTP is configured (onboarding is over).
|
|
|
|
A user-configured config differs from the default, so even under Supervisor
|
|
the transition must not apply.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 9000})
|
|
|
|
with _supervisor_default_config():
|
|
assert await async_setup_component(hass, "http", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 9000
|
|
assert hass.http._legacy_redirect_server is None
|
|
|
|
|
|
async def test_redirect_bind_failure_is_handled(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test setup continues when the legacy redirect server cannot bind."""
|
|
with (
|
|
patch.dict(os.environ, {ENV_SUPERVISOR: "core"}, clear=True),
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server.HomeAssistantHTTP._async_create_redirect_server",
|
|
autospec=True,
|
|
side_effect=OSError(errno.EADDRINUSE, "Address already in use"),
|
|
),
|
|
):
|
|
assert await async_setup_component(hass, "http", {})
|
|
assert await async_setup_component(hass, "onboarding", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
server = hass.http
|
|
assert server._legacy_redirect_server is None
|
|
assert server._legacy_redirect_runner is None
|
|
|
|
|
|
async def test_legacy_redirect_serves_method_preserving_307(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the legacy redirect serves a 307 to the active port for any method."""
|
|
captured: dict[str, int] = {}
|
|
|
|
async def _bind_serving(self: http.HomeAssistantHTTP) -> asyncio.Server:
|
|
assert self._legacy_redirect_runner is not None
|
|
server = await self.hass.loop.create_server(
|
|
self._legacy_redirect_runner.server, "127.0.0.1", 0
|
|
)
|
|
captured["port"] = server.sockets[0].getsockname()[1]
|
|
return server
|
|
|
|
with (
|
|
patch.dict(os.environ, {ENV_SUPERVISOR: "core"}, clear=True),
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server.HomeAssistantHTTP._async_create_redirect_server",
|
|
autospec=True,
|
|
side_effect=_bind_serving,
|
|
),
|
|
):
|
|
await _setup_http_with_onboarding(hass)
|
|
assert hass.http._legacy_redirect_server is not None
|
|
|
|
async with (
|
|
aiohttp.ClientSession() as session,
|
|
session.post(
|
|
f"http://127.0.0.1:{captured['port']}/api/foo?bar=1",
|
|
allow_redirects=False,
|
|
) as resp,
|
|
):
|
|
# 307 preserves the POST method; only the port changes (80 is
|
|
# the default HTTP port, so it is omitted from the URL).
|
|
assert resp.status == 307
|
|
assert resp.headers["Location"] == "http://127.0.0.1/api/foo?bar=1"
|
|
|
|
|
|
async def test_legacy_redirect_stops_with_connection_open(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test the redirect tears down while a client holds a connection open.
|
|
|
|
A client keeps its connection alive after the redirect response, so
|
|
awaiting the server's wait_closed() before the runner has closed the open
|
|
connections never returned, hanging every shutdown stage in turn.
|
|
"""
|
|
captured: dict[str, int] = {}
|
|
|
|
async def _bind_serving(self: http.HomeAssistantHTTP) -> asyncio.Server:
|
|
assert self._legacy_redirect_runner is not None
|
|
server = await self.hass.loop.create_server(
|
|
self._legacy_redirect_runner.server, "127.0.0.1", 0
|
|
)
|
|
captured["port"] = server.sockets[0].getsockname()[1]
|
|
return server
|
|
|
|
with (
|
|
patch.dict(os.environ, {ENV_SUPERVISOR: "core"}, clear=True),
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
patch(
|
|
"homeassistant.components.http.server.HomeAssistantHTTP._async_create_redirect_server",
|
|
autospec=True,
|
|
side_effect=_bind_serving,
|
|
),
|
|
):
|
|
await _setup_http_with_onboarding(hass)
|
|
server = hass.http
|
|
assert server._legacy_redirect_server is not None
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
f"http://127.0.0.1:{captured['port']}/", allow_redirects=False
|
|
) as resp:
|
|
assert resp.status == 307
|
|
|
|
# The connection is returned to the pool, not closed, so the
|
|
# redirect still has an open connection at teardown.
|
|
_complete_onboarding(hass)
|
|
async with asyncio.timeout(10):
|
|
await hass.async_block_till_done()
|
|
|
|
assert server._legacy_redirect_server is None
|
|
assert server._legacy_redirect_runner is None
|
|
|
|
|
|
async def test_legacy_redirect_stop_is_reentrant(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Test overlapping redirect teardowns do not wait on each other.
|
|
|
|
Onboarding completing and the stop event both tear the redirect down, so
|
|
the second call must not await the first call's server again.
|
|
"""
|
|
with _supervisor_default_config():
|
|
await _setup_http_with_onboarding(hass)
|
|
server = hass.http
|
|
assert server._legacy_redirect_server is not None
|
|
|
|
async with asyncio.timeout(10):
|
|
await asyncio.gather(
|
|
server._async_stop_legacy_redirect(),
|
|
server._async_stop_legacy_redirect(),
|
|
)
|
|
|
|
assert server._legacy_redirect_server is None
|
|
assert server._legacy_redirect_runner is None
|
|
|
|
# The stop event fires the same teardown once more.
|
|
async with asyncio.timeout(10):
|
|
await server._async_stop_legacy_redirect()
|
|
|
|
|
|
@pytest.mark.usefixtures("freezer")
|
|
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."""
|
|
created_at = dt_util.utcnow().isoformat()
|
|
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,
|
|
"active_config_type": "stable",
|
|
"default": _DEFAULT_CONFIG,
|
|
}
|
|
|
|
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}
|
|
assert hass_storage["http"]["data"]["pending"] == {
|
|
**new_config,
|
|
"created_at": created_at,
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
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. The staged config is not active yet: the
|
|
# restart that would apply it is mocked in this test.
|
|
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,
|
|
"created_at": created_at,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"revert_at": None,
|
|
"active_config_type": "stable",
|
|
"default": _DEFAULT_CONFIG,
|
|
}
|
|
|
|
# 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"] == {
|
|
**new_config,
|
|
"created_at": created_at,
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
|
|
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,
|
|
"created_at": created_at,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"pending": None,
|
|
"revert_at": None,
|
|
"active_config_type": "stable",
|
|
"default": _DEFAULT_CONFIG,
|
|
}
|
|
|
|
# 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"] == _stored_config(
|
|
{"server_port": 7000}, created_at=created_at
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 2
|
|
|
|
# Re-staging the identical armed config keeps the entry -> no 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": False}
|
|
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"] == {
|
|
**new_config,
|
|
"created_at": created_at,
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
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_websocket_configure_verifies_new_port(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""Configuring a new port probes that it can be bound before restarting."""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": {"server_port": 9123}}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
|
|
# One bind for setup, one for the probe of the new config.
|
|
assert mock_create_server.call_count == 2
|
|
assert mock_create_server.call_args_list[1].args[0].server_port == 9123
|
|
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bind_error",
|
|
[
|
|
OSError(errno.EADDRINUSE, "Address already in use"),
|
|
PermissionError(errno.EACCES, "Permission denied"),
|
|
socket.gaierror(socket.EAI_NONAME, "Name or service not known"),
|
|
],
|
|
ids=["address-in-use", "permission-denied", "unresolvable-host"],
|
|
)
|
|
async def test_websocket_configure_rejects_unbindable_config(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
mock_create_server: Mock,
|
|
bind_error: OSError,
|
|
) -> None:
|
|
"""A new config whose address cannot be bound is rejected without a restart."""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
mock_create_server.side_effect = bind_error
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": {"server_port": 9123}}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert not response["success"]
|
|
assert response["error"]["code"] == "bind_failed"
|
|
assert response["error"]["message"] == (
|
|
f"Failed to create HTTP server at port 9123: {bind_error}"
|
|
)
|
|
|
|
# The rejected config is not stored and no restart is triggered.
|
|
assert hass_storage[DOMAIN]["data"]["pending"] is None
|
|
assert len(restart_calls) == 0
|
|
|
|
|
|
async def test_websocket_configure_rejects_unusable_ssl(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A new config whose SSL certificate cannot be loaded is rejected."""
|
|
cert_path, key_path = _setup_broken_ssl_pem_files(tmp_path)
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
ws_client = await hass_ws_client(hass)
|
|
await ws_client.send_json_auto_id(
|
|
{
|
|
"type": "http/config/configure",
|
|
"config": {
|
|
"server_port": 9123,
|
|
"ssl_certificate": str(cert_path),
|
|
"ssl_key": str(key_path),
|
|
},
|
|
}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert not response["success"]
|
|
assert response["error"]["code"] == "bind_failed"
|
|
assert "Could not use SSL certificate" in response["error"]["message"]
|
|
|
|
assert hass_storage[DOMAIN]["data"]["pending"] is None
|
|
assert len(restart_calls) == 0
|
|
|
|
|
|
async def test_websocket_configure_same_port_skips_bind_check(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""No bind probe runs when the new config keeps the currently bound port.
|
|
|
|
The running server holds the port until the restart releases it, so a
|
|
probe would always fail against ourselves.
|
|
"""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
# Any probe would fail; success proves the check was skipped.
|
|
mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use")
|
|
|
|
current_port = default_server_port()
|
|
ws_client = await hass_ws_client(hass)
|
|
await ws_client.send_json_auto_id(
|
|
{
|
|
"type": "http/config/configure",
|
|
"config": {
|
|
"server_port": current_port,
|
|
"cors_allowed_origins": ["https://example.com"],
|
|
},
|
|
}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
assert hass_storage[DOMAIN]["data"]["pending"]["server_port"] == current_port
|
|
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"core_state",
|
|
[CoreState.not_running, CoreState.starting],
|
|
ids=["not-running", "starting"],
|
|
)
|
|
async def test_websocket_configure_rejected_while_not_running(
|
|
hass: HomeAssistant,
|
|
hass_ws_client: WebSocketGenerator,
|
|
hass_storage: dict[str, Any],
|
|
mock_create_server: Mock,
|
|
core_state: CoreState,
|
|
) -> None:
|
|
"""Staging a new config is rejected while Home Assistant is not running yet."""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await async_setup_component(hass, "websocket_api", {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
ws_client = await hass_ws_client(hass)
|
|
|
|
hass.set_state(core_state)
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": {"server_port": 9123}}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert not response["success"]
|
|
assert response["error"]["code"] == "not_running"
|
|
assert response["error"]["message"] == (
|
|
"The HTTP configuration can only be changed while Home Assistant is "
|
|
f"running, current state: {core_state.value}"
|
|
)
|
|
|
|
# The config is neither probed nor stored, and no restart is triggered.
|
|
assert mock_create_server.call_count == 1
|
|
assert hass_storage[DOMAIN]["data"]["pending"] is None
|
|
assert len(restart_calls) == 0
|
|
|
|
# Once the start finished, the same config is accepted.
|
|
hass.set_state(CoreState.running)
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": {"server_port": 9123}}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
assert hass_storage[DOMAIN]["data"]["pending"]["server_port"] == 9123
|
|
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 1
|
|
|
|
|
|
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
|
|
|
|
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": _stored_config({"server_port": 9876}, created_at=STABLE_CREATED_AT),
|
|
"pending": _stored_config({"server_port": 9999}),
|
|
"revert_at": revert_at.isoformat(),
|
|
"active_config_type": "pending",
|
|
"default": _DEFAULT_CONFIG,
|
|
}
|
|
|
|
# After the delay elapses without a promotion, pending is marked reverted
|
|
# 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": _stored_config({"server_port": 9876}, created_at=STABLE_CREATED_AT),
|
|
"pending": _stored_config({"server_port": 9999}, error=ERROR_NOT_PROMOTED),
|
|
"yaml_migration_done": True,
|
|
}
|
|
assert len(restart_calls) == 1
|
|
|
|
# The reverted config cannot be promoted; it must be staged again.
|
|
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"
|
|
|
|
# Re-staging the same config arms a fresh trial -> restart.
|
|
await ws_client.send_json_auto_id(
|
|
{"type": "http/config/configure", "config": {"server_port": 9999}}
|
|
)
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {"restart": True}
|
|
assert hass_storage["http"]["data"]["pending"] == _stored_config(
|
|
{"server_port": 9999}, created_at=dt_util.utcnow().isoformat()
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert len(restart_calls) == 2
|
|
|
|
|
|
async def test_setup_ignores_reverted_pending(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""A reverted pending config is kept for display but never trialed again."""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876},
|
|
pending={"server_port": 9999},
|
|
pending_error=ERROR_NOT_PROMOTED,
|
|
)
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.config.api.port == 9876
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.STABLE
|
|
assert store.revert_deadline is None
|
|
assert store.pending == _stored_config(
|
|
{"server_port": 9999}, error=ERROR_NOT_PROMOTED
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bind_error",
|
|
[
|
|
OSError(errno.EADDRINUSE, "Address already in use"),
|
|
PermissionError(errno.EACCES, "Permission denied"),
|
|
socket.gaierror(socket.EAI_NONAME, "Name or service not known"),
|
|
],
|
|
ids=["address-in-use", "permission-denied", "unresolvable-host"],
|
|
)
|
|
@pytest.mark.usefixtures("freezer")
|
|
async def test_pending_config_reverted_in_place_on_bind_failure(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
caplog: pytest.LogCaptureFixture,
|
|
mock_create_server: Mock,
|
|
bind_error: OSError,
|
|
) -> None:
|
|
"""A pending config that cannot be bound is reverted within the same start.
|
|
|
|
The trial fails while the config is realized during setup, so the stable
|
|
config is applied in place - no restart, no waiting out the trial window.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876}, pending={"server_port": 80}
|
|
)
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
stable_server = await _ephemeral_server(hass)
|
|
mock_create_server.side_effect = [bind_error, stable_server]
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
# The pending config is kept as a draft for the frontend to show alongside
|
|
# the error persisted on it; this same start continues on stable.
|
|
assert hass_storage["http"]["data"] == {
|
|
"stable": _stored_config({"server_port": 9876}, created_at=STABLE_CREATED_AT),
|
|
"pending": _stored_config(
|
|
{"server_port": 80},
|
|
error=ERROR_APPLY_FAILED,
|
|
error_message=str(bind_error),
|
|
),
|
|
"yaml_migration_done": True,
|
|
}
|
|
assert hass.config.api is not None
|
|
assert hass.config.api.port == 9876
|
|
# The second bind attempt was for the stable config.
|
|
assert mock_create_server.call_args_list[1].args[0].server_port == 9876
|
|
# No restart is involved and no revert stays scheduled.
|
|
assert len(restart_calls) == 0
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.revert_deadline is None
|
|
assert store.active_config_type is ActiveConfigType.STABLE
|
|
assert "could not be applied, reverting" in caplog.text
|
|
assert "previous HTTP configuration has been restored (server port 9876)" in (
|
|
caplog.text
|
|
)
|
|
|
|
# Staging a new config supersedes the failed one.
|
|
await store.async_set_pending(HTTP_STORAGE_SCHEMA({"server_port": 9000}))
|
|
assert store.pending == {
|
|
**HTTP_STORAGE_SCHEMA({"server_port": 9000}),
|
|
"created_at": dt_util.utcnow().isoformat(),
|
|
"error": None,
|
|
"error_message": None,
|
|
}
|
|
|
|
stable_server.close()
|
|
await stable_server.wait_closed()
|
|
|
|
|
|
async def test_pending_config_reverted_in_place_on_ssl_failure(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""A pending config whose SSL certificate is unusable reverts in place."""
|
|
stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876}))
|
|
# Craft the raw storage payload: the schema validates that the SSL files
|
|
# exist when the config is set, but they can vanish before the next start.
|
|
pending = dict(HTTP_STORAGE_SCHEMA({"server_port": 9999}))
|
|
pending["ssl_certificate"] = "/nonexistent/cert.pem"
|
|
pending["ssl_key"] = "/nonexistent/key.pem"
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"minor_version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": {
|
|
**stable,
|
|
"created_at": STABLE_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"pending": {
|
|
**pending,
|
|
"created_at": PENDING_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"yaml_migration_done": True,
|
|
},
|
|
}
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
stored_pending = hass_storage["http"]["data"]["pending"]
|
|
assert stored_pending == {
|
|
**pending,
|
|
"created_at": PENDING_CREATED_AT,
|
|
"error": ERROR_APPLY_FAILED,
|
|
"error_message": ANY,
|
|
}
|
|
assert hass.config.api is not None
|
|
assert hass.config.api.port == 9876
|
|
assert hass.config.api.use_ssl is False
|
|
assert len(restart_calls) == 0
|
|
assert (
|
|
"Could not use SSL certificate from /nonexistent/cert.pem"
|
|
in stored_pending["error_message"]
|
|
)
|
|
|
|
|
|
async def test_pending_config_reverted_in_place_on_ssl_peer_cert_failure(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A pending config whose SSL peer certificate is unusable reverts in place."""
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876}))
|
|
pending = dict(
|
|
HTTP_STORAGE_SCHEMA(
|
|
{
|
|
"server_port": 9999,
|
|
"ssl_certificate": str(cert_path),
|
|
"ssl_key": str(key_path),
|
|
}
|
|
)
|
|
)
|
|
# The peer certificate vanished after the config was stored.
|
|
pending["ssl_peer_certificate"] = "/nonexistent/peer.pem"
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"minor_version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": {
|
|
**stable,
|
|
"created_at": STABLE_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"pending": {
|
|
**pending,
|
|
"created_at": PENDING_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"yaml_migration_done": True,
|
|
},
|
|
}
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
|
|
with patch("ssl.SSLContext.load_cert_chain"):
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
stored_pending = hass_storage["http"]["data"]["pending"]
|
|
assert stored_pending == {
|
|
**pending,
|
|
"created_at": PENDING_CREATED_AT,
|
|
"error": ERROR_APPLY_FAILED,
|
|
"error_message": ANY,
|
|
}
|
|
assert stored_pending["error_message"] is not None
|
|
assert hass.config.api is not None
|
|
assert hass.config.api.port == 9876
|
|
assert hass.config.api.use_ssl is False
|
|
assert len(restart_calls) == 0
|
|
|
|
|
|
async def test_stable_config_ssl_peer_cert_failure_fails_setup(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A stable config whose SSL peer certificate is unusable fails setup.
|
|
|
|
An unusable stable SSL configuration must fail setup, activating recovery
|
|
mode on a real boot.
|
|
"""
|
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
|
_setup_empty_ssl_pem_files, tmp_path
|
|
)
|
|
stable = dict(
|
|
HTTP_STORAGE_SCHEMA(
|
|
{
|
|
"server_port": 9876,
|
|
"ssl_certificate": str(cert_path),
|
|
"ssl_key": str(key_path),
|
|
}
|
|
)
|
|
)
|
|
stable["ssl_peer_certificate"] = "/nonexistent/peer.pem"
|
|
hass_storage[DOMAIN] = {
|
|
"version": 2,
|
|
"minor_version": 2,
|
|
"key": DOMAIN,
|
|
"data": {
|
|
"stable": {
|
|
**stable,
|
|
"created_at": STABLE_CREATED_AT,
|
|
"error": None,
|
|
"error_message": None,
|
|
},
|
|
"pending": None,
|
|
"yaml_migration_done": True,
|
|
},
|
|
}
|
|
|
|
with patch("ssl.SSLContext.load_cert_chain"):
|
|
assert await async_setup_component(hass, DOMAIN, {}) is False
|
|
|
|
|
|
async def test_bound_server_closed_on_stop_before_start(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""A bound server is closed on stop even if it never started serving.
|
|
|
|
If setup fails after binding (or recovery mode tears Home Assistant down
|
|
before serving starts), the stop event must close the server so a
|
|
follow-up boot in the same process can bind the address again.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 9876})
|
|
|
|
server = await _ephemeral_server(hass)
|
|
mock_create_server.side_effect = [server]
|
|
|
|
with patch.object(
|
|
http.HomeAssistantHTTP,
|
|
"async_initialize",
|
|
side_effect=HomeAssistantError("Setup failed after binding"),
|
|
):
|
|
assert not await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert server.sockets
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
assert not server.sockets
|
|
|
|
|
|
async def test_stop_completes_with_open_connections(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
) -> None:
|
|
"""Stopping the server must not wait for connected clients to disconnect.
|
|
|
|
Server.wait_closed() waits for all client connections to terminate since
|
|
Python 3.12.1, but connections are only torn down by the runner cleanup,
|
|
so waiting for them first hangs shutdown while a client (e.g. an open
|
|
websocket) stays connected.
|
|
"""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
await hass.async_start()
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.http._server is not None
|
|
port = hass.http._server.sockets[0].getsockname()[1]
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
|
|
try:
|
|
# Complete a request/response round trip so the connection is
|
|
# accepted and tracked by the server (a mere TCP connect may still
|
|
# sit in the listen backlog), then keep the connection open.
|
|
writer.write(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
|
await writer.drain()
|
|
assert await reader.readline()
|
|
|
|
await asyncio.wait_for(hass.http.stop(), timeout=15)
|
|
finally:
|
|
writer.close()
|
|
|
|
|
|
async def test_stable_config_bind_failure_fails_setup(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""A stable config that cannot be bound fails setup.
|
|
|
|
Failing setup activates recovery mode on a real boot, which retries with
|
|
the stable config and falls back to the default config, so Home Assistant
|
|
stays reachable.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80})
|
|
|
|
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
|
mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use")
|
|
|
|
assert not await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert len(restart_calls) == 0
|
|
assert hass_storage["http"]["data"] == {
|
|
"stable": _stored_config({"server_port": 80}, created_at=STABLE_CREATED_AT),
|
|
"pending": None,
|
|
"yaml_migration_done": True,
|
|
}
|
|
|
|
|
|
async def test_pending_and_stable_config_bind_failure_fails_setup(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""Setup fails when the trialed pending and the stable config cannot bind.
|
|
|
|
The pending config is kept as a draft so the user can review or amend it;
|
|
the recovery boot ignores it and uses stable.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage(
|
|
{"server_port": 9876}, pending={"server_port": 80}
|
|
)
|
|
|
|
bind_error = OSError(errno.EADDRINUSE, "Address already in use")
|
|
mock_create_server.side_effect = [bind_error, bind_error]
|
|
|
|
assert not await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert hass_storage["http"]["data"] == {
|
|
"stable": _stored_config({"server_port": 9876}, created_at=STABLE_CREATED_AT),
|
|
"pending": _stored_config(
|
|
{"server_port": 80},
|
|
error=ERROR_APPLY_FAILED,
|
|
error_message=str(bind_error),
|
|
),
|
|
"yaml_migration_done": True,
|
|
}
|
|
|
|
|
|
async def test_create_server_normalizes_unencodable_host(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""A host name the IDNA codec cannot encode raises OSError.
|
|
|
|
create_server() raises UnicodeError (a ValueError) for such host names,
|
|
e.g. a label longer than 63 characters; it must be normalized to OSError
|
|
so the config fallback chain handles it like any other bind failure.
|
|
"""
|
|
server = http.HomeAssistantHTTP(
|
|
hass,
|
|
server_host=[f"{'x' * 64}.example"],
|
|
server_port=8123,
|
|
ssl_certificate=None,
|
|
ssl_peer_certificate=None,
|
|
ssl_key=None,
|
|
trusted_proxies=[],
|
|
ssl_profile=http.SSL_MODERN,
|
|
)
|
|
with (
|
|
patch.object(
|
|
hass.loop,
|
|
"create_server",
|
|
side_effect=UnicodeError(
|
|
"encoding with 'idna' codec failed (UnicodeError: label too long)"
|
|
),
|
|
),
|
|
pytest.raises(OSError, match="error while resolving host"),
|
|
):
|
|
await _REAL_CREATE_SERVER(server)
|
|
|
|
|
|
async def test_recovery_mode_bind_failure_falls_back_to_default_config(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
caplog: pytest.LogCaptureFixture,
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""In recovery mode an unbindable stable config falls back to defaults.
|
|
|
|
Recovery mode is the last resort and must not fail setup again, so the
|
|
default config is applied in place to keep the recovery UI reachable.
|
|
The stable config is left untouched.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80})
|
|
hass.config.recovery_mode = True
|
|
|
|
default_server = await _ephemeral_server(hass)
|
|
bind_error = OSError(errno.EADDRINUSE, "Address already in use")
|
|
mock_create_server.side_effect = [bind_error, default_server]
|
|
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert "falling back to the default configuration" in caplog.text
|
|
assert hass.config.api is not None
|
|
assert hass.config.api.port == default_server_port()
|
|
# The second bind attempt was for the default config.
|
|
assert mock_create_server.call_args_list[1].args[0].server_port == (
|
|
default_server_port()
|
|
)
|
|
assert hass_storage["http"]["data"]["stable"] == _stored_config(
|
|
{"server_port": 80}, created_at=STABLE_CREATED_AT
|
|
)
|
|
store = await async_get_and_load_store(hass)
|
|
assert store.active_config_type is ActiveConfigType.DEFAULT
|
|
# The bind failure is recorded on the stable slot in memory only; stable
|
|
# errors are never persisted.
|
|
assert store.stable == _stored_config(
|
|
{"server_port": 80},
|
|
created_at=STABLE_CREATED_AT,
|
|
error=ERROR_APPLY_FAILED,
|
|
error_message=str(bind_error),
|
|
)
|
|
default_server.close()
|
|
await default_server.wait_closed()
|
|
|
|
|
|
async def test_recovery_mode_default_config_bind_failure_fails_setup(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
caplog: pytest.LogCaptureFixture,
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""Setup fails in recovery mode when even the default config cannot bind.
|
|
|
|
The fallback chain is exhausted; failing setup makes the failure visible
|
|
to the outside (e.g. the Supervisor rolls back a Core update whose API
|
|
does not come up).
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80})
|
|
hass.config.recovery_mode = True
|
|
|
|
mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use")
|
|
|
|
assert not await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert f"Failed to create HTTP server at port {default_server_port()}" in (
|
|
caplog.text
|
|
)
|
|
|
|
|
|
async def test_recovery_mode_supervisor_falls_back_to_legacy_port(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
caplog: pytest.LogCaptureFixture,
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""Under Supervisor, an unbindable default port 80 falls back to 8123.
|
|
|
|
_DEFAULT_CONFIG is frozen at import (port 8123 without Supervisor), so it is
|
|
patched to the port-80 Supervisor default; the legacy fallback uses the real
|
|
_DEFAULT_CONFIG_LEGACY_PORT (port 8123).
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80})
|
|
hass.config.recovery_mode = True
|
|
|
|
legacy_server = await _ephemeral_server(hass)
|
|
# stable (80) and default (80) fail; the legacy default (8123) binds.
|
|
mock_create_server.side_effect = [
|
|
OSError(errno.EADDRINUSE, "Address already in use"),
|
|
OSError(errno.EADDRINUSE, "Address already in use"),
|
|
legacy_server,
|
|
]
|
|
|
|
with (
|
|
patch.dict(os.environ, {ENV_SUPERVISOR: "core"}),
|
|
patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG
|
|
),
|
|
):
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert "falling back to the previous default port 8123" in caplog.text
|
|
assert hass.config.api.port == 8123
|
|
legacy_server.close()
|
|
await legacy_server.wait_closed()
|
|
|
|
|
|
async def test_recovery_mode_no_legacy_fallback_without_supervisor(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
caplog: pytest.LogCaptureFixture,
|
|
mock_create_server: Mock,
|
|
) -> None:
|
|
"""A non-Supervisor default-port override must not fall back to port 8123.
|
|
|
|
Port 8123 is not exposed for a plain container install using SETUP_PORT, so
|
|
recovery must fail instead of reporting success on an unreachable port.
|
|
"""
|
|
hass_storage[DOMAIN] = _stable_http_storage({"server_port": 8080})
|
|
hass.config.recovery_mode = True
|
|
|
|
mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use")
|
|
|
|
with patch(
|
|
"homeassistant.components.http.config._DEFAULT_CONFIG",
|
|
HTTP_STORAGE_SCHEMA({"server_port": 8080}),
|
|
):
|
|
assert not await async_setup_component(hass, DOMAIN, {})
|
|
|
|
assert "previous default port" not in caplog.text
|
|
|
|
|
|
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")
|
|
|
|
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, and the running
|
|
# config is now reported as stable since promotion moved it to that slot.
|
|
await ws_client.send_json_auto_id({"type": "http/config"})
|
|
response = await ws_client.receive_json()
|
|
assert response["success"]
|
|
assert response["result"] == {
|
|
"stable": _stored_config({"server_port": 9999}),
|
|
"pending": None,
|
|
"revert_at": None,
|
|
"active_config_type": "stable",
|
|
"default": _DEFAULT_CONFIG,
|
|
}
|
|
|
|
# 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": _stored_config({"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."""
|
|
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"
|