mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 17:00:21 +00:00
Merge branch 'dev' into template_water_heater_onoff
This commit is contained in:
@@ -25,6 +25,22 @@ display:
|
||||
lambda: |-
|
||||
it.circle(64, 64, 50, Color::BLACK);
|
||||
|
||||
- platform: epaper_spi
|
||||
spi_id: spi_bus
|
||||
model: waveshare-1.54in-G
|
||||
cs_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO5
|
||||
dc_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO17
|
||||
reset_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO16
|
||||
busy_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO4
|
||||
|
||||
- platform: epaper_spi
|
||||
spi_id: spi_bus
|
||||
model: waveshare-2.13in-v3
|
||||
|
||||
@@ -20,6 +20,8 @@ lvgl:
|
||||
- id: lvgl_0
|
||||
default_font: space16
|
||||
displays: sdl0
|
||||
top_layer:
|
||||
|
||||
- id: lvgl_1
|
||||
displays: sdl1
|
||||
on_idle:
|
||||
|
||||
@@ -8,6 +8,7 @@ import gzip
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -29,7 +30,7 @@ from esphome.dashboard.entries import (
|
||||
bool_to_entry_state,
|
||||
)
|
||||
from esphome.dashboard.models import build_importable_device_dict
|
||||
from esphome.dashboard.web_server import DashboardSubscriber
|
||||
from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
|
||||
from esphome.zeroconf import DiscoveredImport
|
||||
|
||||
from .common import get_fixture_path
|
||||
@@ -421,7 +422,7 @@ async def test_download_binary_handler_idedata_fallback(
|
||||
|
||||
# Mock idedata response
|
||||
mock_image = Mock()
|
||||
mock_image.path = str(bootloader_file)
|
||||
mock_image.path = bootloader_file
|
||||
mock_idedata_instance = Mock()
|
||||
mock_idedata_instance.extra_flash_images = [mock_image]
|
||||
mock_idedata.return_value = mock_idedata_instance
|
||||
@@ -528,14 +529,22 @@ async def test_download_binary_handler_subdirectory_file_url_encoded(
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
@pytest.mark.parametrize(
|
||||
"attack_path",
|
||||
("attack_path", "expected_code"),
|
||||
[
|
||||
pytest.param("../../../secrets.yaml", id="basic_traversal"),
|
||||
pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"),
|
||||
pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"),
|
||||
pytest.param("/etc/passwd", id="absolute_path"),
|
||||
pytest.param("//etc/passwd", id="double_slash_absolute"),
|
||||
pytest.param("....//secrets.yaml", id="multiple_dots"),
|
||||
pytest.param("../../../secrets.yaml", 403, id="basic_traversal"),
|
||||
pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"),
|
||||
pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"),
|
||||
pytest.param("/etc/passwd", 403, id="absolute_path"),
|
||||
pytest.param("//etc/passwd", 403, id="double_slash_absolute"),
|
||||
pytest.param(
|
||||
"....//secrets.yaml",
|
||||
# On Windows, Path.resolve() treats "..." and "...." as parent
|
||||
# traversal (like ".."), so the path escapes base_dir -> 403.
|
||||
# On Unix, "...." is a literal directory name that stays inside
|
||||
# base_dir but doesn't exist -> 404.
|
||||
403 if sys.platform == "win32" else 404,
|
||||
id="multiple_dots",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_download_binary_handler_path_traversal_protection(
|
||||
@@ -543,11 +552,14 @@ async def test_download_binary_handler_path_traversal_protection(
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
attack_path: str,
|
||||
expected_code: int,
|
||||
) -> None:
|
||||
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
|
||||
|
||||
Verifies that attempts to use '..' in file paths are sanitized to prevent
|
||||
accessing files outside the build directory. Tests multiple attack vectors.
|
||||
Verifies that attempts to escape the build directory via '..' are rejected
|
||||
using resolve()/relative_to() validation. Tests multiple attack vectors.
|
||||
Real traversals that escape the base directory get 403. Paths like '....'
|
||||
that resolve inside the base directory but don't exist get 404.
|
||||
"""
|
||||
# Create build structure
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
@@ -565,14 +577,67 @@ async def test_download_binary_handler_path_traversal_protection(
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Attempt path traversal attack - should be blocked
|
||||
with pytest.raises(HTTPClientError) as exc_info:
|
||||
# Mock async_run_system_command so paths that pass validation but don't exist
|
||||
# return 404 deterministically without spawning a real subprocess.
|
||||
with (
|
||||
patch(
|
||||
"esphome.dashboard.web_server.async_run_system_command",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(2, "", ""),
|
||||
),
|
||||
pytest.raises(HTTPClientError) as exc_info,
|
||||
):
|
||||
await dashboard.fetch(
|
||||
f"/download.bin?configuration=test.yaml&file={attack_path}",
|
||||
method="GET",
|
||||
)
|
||||
# Should get 404 (file not found after sanitization) or 500 (idedata fails)
|
||||
assert exc_info.value.code in (404, 500)
|
||||
assert exc_info.value.code == expected_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_no_firmware_bin_path(
|
||||
dashboard: DashboardTestHelper,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test that download returns 404 when firmware_bin_path is None.
|
||||
|
||||
This covers configs created by StorageJSON.from_wizard() where no
|
||||
firmware has been compiled yet.
|
||||
"""
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = None
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
with pytest.raises(HTTPClientError) as exc_info:
|
||||
await dashboard.fetch(
|
||||
"/download.bin?configuration=test.yaml&file=firmware.bin",
|
||||
method="GET",
|
||||
)
|
||||
assert exc_info.value.code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"])
|
||||
async def test_download_binary_handler_empty_file_name(
|
||||
dashboard: DashboardTestHelper,
|
||||
mock_storage_json: MagicMock,
|
||||
file_value: str,
|
||||
) -> None:
|
||||
"""Test that download returns 400 for empty or whitespace-only file names."""
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = Path("/fake/firmware.bin")
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
with pytest.raises(HTTPClientError) as exc_info:
|
||||
await dashboard.fetch(
|
||||
f"/download.bin?configuration=test.yaml&file={file_value}",
|
||||
method="GET",
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1654,3 +1719,25 @@ async def test_websocket_check_origin_multiple_trusted_domains(
|
||||
assert data["event"] == "initial_state"
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
def test_proc_on_exit_calls_close() -> None:
|
||||
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
|
||||
handler = Mock(spec=EsphomeCommandWebSocket)
|
||||
handler._is_closed = False
|
||||
|
||||
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
|
||||
|
||||
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
|
||||
handler.close.assert_called_once()
|
||||
|
||||
|
||||
def test_proc_on_exit_skips_when_already_closed() -> None:
|
||||
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
|
||||
handler = Mock(spec=EsphomeCommandWebSocket)
|
||||
handler._is_closed = True
|
||||
|
||||
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
|
||||
|
||||
handler.write_message.assert_not_called()
|
||||
handler.close.assert_not_called()
|
||||
|
||||
@@ -93,23 +93,34 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]]
|
||||
sock.close()
|
||||
|
||||
|
||||
def _get_free_udp_port() -> int:
|
||||
"""Get a free UDP port by binding to port 0 and releasing."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_udp_send_receive(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test UDP component can send messages with multiple addresses configured."""
|
||||
# Track log lines to verify dump_config output
|
||||
"""Test UDP component can send and receive messages."""
|
||||
log_lines: list[str] = []
|
||||
receive_event = asyncio.Event()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
log_lines.append(line)
|
||||
if "Received UDP:" in line:
|
||||
receive_event.set()
|
||||
|
||||
async with udp_listener() as (udp_port, receiver):
|
||||
# Replace placeholders in the config
|
||||
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1))
|
||||
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port))
|
||||
async with udp_listener() as (broadcast_port, receiver):
|
||||
listen_port = _get_free_udp_port()
|
||||
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port))
|
||||
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port))
|
||||
|
||||
async with (
|
||||
run_compiled(config, line_callback=on_log_line),
|
||||
@@ -169,3 +180,19 @@ async def test_udp_send_receive(
|
||||
assert "Address: 127.0.0.2" in log_text, (
|
||||
f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}"
|
||||
)
|
||||
|
||||
# Test receiving a UDP packet (exercises on_receive with std::span)
|
||||
test_payload = b"TEST_RECEIVE_UDP"
|
||||
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
send_sock.sendto(test_payload, ("127.0.0.1", listen_port))
|
||||
finally:
|
||||
send_sock.close()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(receive_event.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"on_receive did not fire. Expected 'Received UDP:' in logs. "
|
||||
f"Last log lines: {log_lines[-20:]}"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
@@ -632,3 +632,14 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
|
||||
# rpipico doesn't support WiFi, so no api_encryption_key or ota_password
|
||||
assert "api_encryption_key" not in call_kwargs
|
||||
assert "ota_password" not in call_kwargs
|
||||
|
||||
|
||||
def test_fallback_psk_uses_secrets_choice(
|
||||
default_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test that fallback PSK is generated using secrets.choice."""
|
||||
with patch("esphome.wizard.secrets.choice", return_value="X") as mock_choice:
|
||||
config = wz.wizard_file(**default_config)
|
||||
|
||||
assert 'password: "XXXXXXXXXXXX"' in config
|
||||
assert mock_choice.call_count == 12
|
||||
|
||||
Reference in New Issue
Block a user