0e3ae78de7
Closes the issues surfaced by the pre-merge code review of the expand-device-support branch. CRITICAL #2 -- update_device double-encrypts secrets in memory. storage/device_store.py round-tripped through device.to_dict() which encrypts hue_username / hue_client_key / ble_govee_key / nanoleaf_token via _enc(), but Device.__init__ does not decrypt. The cached self._items[device_id] thus held ciphertext where plaintext belonged, breaking runtime auth for paired devices on any update -- even an innocuous rename. Sourcing kwargs from vars(device) directly avoids the round-trip. Regression tests cover Nanoleaf and Hue. HIGH #3 -- secrets leaked in GET /api/v1/devices response. DeviceResponse previously returned nanoleaf_token / hue_username / hue_client_key in plaintext (decrypted server-side from storage), defeating the encryption-at-rest. Replaced with nanoleaf_paired and hue_paired booleans. ble_govee_key intentionally stays -- it's a user-managed value pasted from a third-party tool, must remain visible for edit. Frontend types.ts + the one nanoleaf_token reader updated to the boolean. HIGH #4 -- SSRF surface. validate_lan_host() added to net_classify.py; called from each new driver's validate_device (DDP / Yeelight / WiZ / LIFX / Govee / OPC / Nanoleaf) and from pair_device. Rejects literal public IPs with a descriptive ValueError; non-IP hostnames pass through (mDNS labels, bare hostnames). RFC6890 ranges (documentation, former class E) are accepted as LAN-like since Python's ipaddress.is_private treats them so -- correct policy for LedGrab. HIGH #5 -- decrypt failure deletes the device row. _dec() now catches the exception, logs an error, and returns "" instead of propagating. Without the fix, a regenerated data/.secret_key would silently make every Hue / Nanoleaf / BLE-Govee device disappear from the device list on next startup. Regression test asserts a corrupt envelope leaves the device hydratable. HIGH #6 -- update_device route does not rstrip("/") for non-WLED. Moved the trim before the WLED-specific scheme inference so every device type gets consistent URL normalization between create and update. MEDIUM #7 -- Govee discovery port 4002 collision. Added a lazily- initialized module-level asyncio.Lock that serializes concurrent discover_govee_devices() calls; the previous behavior had the second parallel scan silently return [] when the first still held port 4002. Error message also clarified to mention another Govee tool. MEDIUM #8 -- Nanoleaf discover() leaked browser tasks on cancellation. Moved the browser cancel loop into the finally block so an interrupted mDNS scan still tears them down. MEDIUM #9 -- pair endpoint logged user-supplied URL with exc_info=True. Added _sanitize_url_for_log() that strips userinfo + fragment, and demoted the log from exc_info to type(exc).__name__ + str(exc) so a hostile receiver's response body can't end up in the log file. LOW -- Nanoleaf was the only client without a .port property. Added one (returns NANOLEAF_PORT, fixed) for cross-driver symmetry. LOW -- no end-to-end pair-then-create coverage. Added TestPairThenCreateFlow.test_pair_then_create_persists_encrypted_token which exercises the full path: POST /api/v1/devices/pair returned fields, store.create_device, then asserts (a) in-memory plaintext, (b) to_config() plaintext, (c) persisted ciphertext, (d) API response strip + paired-boolean. Tests: 1379 pass (was 1358 -- 21 new regression tests added). ruff clean. TypeScript clean.
471 lines
14 KiB
Python
471 lines
14 KiB
Python
"""Tests for the Nanoleaf OpenAPI LED client + provider."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from ledgrab.core.devices.device_config import NanoleafConfig
|
|
from ledgrab.core.devices.led_client import PairingNotReady, ProviderDeps
|
|
from ledgrab.core.devices.nanoleaf_client import (
|
|
NANOLEAF_PORT,
|
|
NanoleafClient,
|
|
_average_color,
|
|
pair_nanoleaf,
|
|
parse_nanoleaf_url,
|
|
rgb_to_hsb,
|
|
)
|
|
from ledgrab.core.devices.nanoleaf_provider import NanoleafDeviceProvider
|
|
|
|
|
|
# ============================================================================
|
|
# URL parsing
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url,expected",
|
|
[
|
|
("nanoleaf://192.168.1.50", "192.168.1.50"),
|
|
("nanoleaf://192.168.1.50:16021", "192.168.1.50"),
|
|
("192.168.1.50", "192.168.1.50"),
|
|
("nanoleaf://controller.local", "controller.local"),
|
|
],
|
|
)
|
|
def test_parse_nanoleaf_url(url, expected):
|
|
assert parse_nanoleaf_url(url) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["", " ", "nanoleaf://", "://1.2.3.4"])
|
|
def test_parse_nanoleaf_url_rejects_empty(url):
|
|
with pytest.raises(ValueError):
|
|
parse_nanoleaf_url(url)
|
|
|
|
|
|
# ============================================================================
|
|
# RGB → HSB conversion (Nanoleaf scale: H 0-360, S 0-100, B 0-100)
|
|
# ============================================================================
|
|
|
|
|
|
def test_rgb_to_hsb_pure_red():
|
|
h, s, b = rgb_to_hsb(255, 0, 0)
|
|
assert h == 0
|
|
assert s == 100
|
|
assert b == 100
|
|
|
|
|
|
def test_rgb_to_hsb_pure_green():
|
|
h, s, b = rgb_to_hsb(0, 255, 0)
|
|
assert h == 120
|
|
assert s == 100
|
|
assert b == 100
|
|
|
|
|
|
def test_rgb_to_hsb_pure_blue():
|
|
h, s, b = rgb_to_hsb(0, 0, 255)
|
|
assert h == 240
|
|
assert s == 100
|
|
assert b == 100
|
|
|
|
|
|
def test_rgb_to_hsb_white_zero_saturation():
|
|
h, s, b = rgb_to_hsb(255, 255, 255)
|
|
assert s == 0
|
|
assert b == 100
|
|
# Hue is undefined for pure white; we deterministically pick 0
|
|
assert h == 0
|
|
|
|
|
|
def test_rgb_to_hsb_black_zero_brightness():
|
|
h, s, b = rgb_to_hsb(0, 0, 0)
|
|
assert b == 0
|
|
assert s == 0
|
|
assert h == 0
|
|
|
|
|
|
def test_rgb_to_hsb_clamps_out_of_range():
|
|
h, s, b = rgb_to_hsb(-10, 999, 128)
|
|
assert 0 <= h < 360
|
|
assert 0 <= s <= 100
|
|
assert 0 <= b <= 100
|
|
|
|
|
|
# ============================================================================
|
|
# _average_color
|
|
# ============================================================================
|
|
|
|
|
|
def test_average_color_numpy():
|
|
pixels = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90]], dtype=np.uint8)
|
|
assert _average_color(pixels) == (40, 50, 60)
|
|
|
|
|
|
def test_average_color_list_and_empty():
|
|
assert _average_color([(10, 0, 0), (20, 0, 0), (30, 0, 0)]) == (20, 0, 0)
|
|
assert _average_color([]) == (0, 0, 0)
|
|
|
|
|
|
# ============================================================================
|
|
# pair_nanoleaf — HTTP handshake
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pair_nanoleaf_returns_token_on_200(respx_mock):
|
|
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
return_value=httpx.Response(200, json={"auth_token": "tok-abc"}),
|
|
)
|
|
token = await pair_nanoleaf("1.2.3.4")
|
|
assert token == "tok-abc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pair_nanoleaf_raises_pairing_not_ready_on_403(respx_mock):
|
|
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
return_value=httpx.Response(403, text="not pairing"),
|
|
)
|
|
with pytest.raises(PairingNotReady) as exc:
|
|
await pair_nanoleaf("1.2.3.4")
|
|
# The user-facing message must mention the physical action.
|
|
assert "power button" in str(exc.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pair_nanoleaf_raises_runtime_error_on_500(respx_mock):
|
|
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
return_value=httpx.Response(500, text="boom"),
|
|
)
|
|
with pytest.raises(RuntimeError, match="HTTP 500"):
|
|
await pair_nanoleaf("1.2.3.4")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pair_nanoleaf_rejects_missing_token(respx_mock):
|
|
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
return_value=httpx.Response(200, json={"not_a_token": "x"}),
|
|
)
|
|
with pytest.raises(RuntimeError, match="no auth_token"):
|
|
await pair_nanoleaf("1.2.3.4")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pair_nanoleaf_wraps_transport_error(respx_mock):
|
|
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
side_effect=httpx.ConnectError("refused", request=None),
|
|
)
|
|
with pytest.raises(RuntimeError, match="transport failure"):
|
|
await pair_nanoleaf("1.2.3.4")
|
|
|
|
|
|
# ============================================================================
|
|
# NanoleafClient (mocked HTTP)
|
|
# ============================================================================
|
|
|
|
|
|
def _make_connected_client(token: str = "tok-abc") -> NanoleafClient:
|
|
"""Build a NanoleafClient with a mocked httpx.AsyncClient."""
|
|
client = NanoleafClient(
|
|
"nanoleaf://1.2.3.4", led_count=10, auth_token=token, min_interval_s=0.0
|
|
)
|
|
http_mock = MagicMock()
|
|
http_mock.put = AsyncMock(return_value=httpx.Response(204))
|
|
http_mock.aclose = AsyncMock()
|
|
client._http = http_mock
|
|
client._connected = True
|
|
return client
|
|
|
|
|
|
def _last_put_body(client: NanoleafClient) -> dict:
|
|
"""Pull the JSON body out of the most recent put() call."""
|
|
assert client._http.put.called
|
|
return client._http.put.call_args.kwargs["json"]
|
|
|
|
|
|
def _last_put_url(client: NanoleafClient) -> str:
|
|
return client._http.put.call_args.args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_emits_hsb_state_put():
|
|
client = _make_connected_client(token="abc123")
|
|
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels)
|
|
|
|
body = _last_put_body(client)
|
|
assert "hue" in body
|
|
assert "sat" in body
|
|
assert "brightness" in body
|
|
assert body["hue"]["value"] == 0
|
|
assert body["sat"]["value"] == 100
|
|
assert body["brightness"]["value"] == 100
|
|
# duration: 0 keeps the transition instant — critical for ambilight
|
|
assert body["brightness"]["duration"] == 0
|
|
assert "/api/v1/abc123/state" in _last_put_url(client)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_clamps_brightness_to_at_least_1():
|
|
"""Nanoleaf rejects brightness=0; clamping to 1 keeps the API happy."""
|
|
client = _make_connected_client()
|
|
pixels = np.array([[0, 0, 0]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels)
|
|
|
|
body = _last_put_body(client)
|
|
assert body["brightness"]["value"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_applies_brightness_scale():
|
|
client = _make_connected_client()
|
|
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels, brightness=128)
|
|
|
|
body = _last_put_body(client)
|
|
# 255 * 128/255 = 128 → brightness ~128/255 of full = ~50% of HSB scale
|
|
assert 40 <= body["brightness"]["value"] <= 60
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_when_not_connected_raises():
|
|
client = NanoleafClient("nanoleaf://1.2.3.4", led_count=1, auth_token="abc")
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
await client.send_pixels([(1, 2, 3)])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_power_emits_on_value():
|
|
client = _make_connected_client()
|
|
|
|
await client.set_power(True)
|
|
await client.set_power(False)
|
|
|
|
bodies = [call.kwargs["json"] for call in client._http.put.call_args_list]
|
|
assert bodies[0] == {"on": {"value": True}}
|
|
assert bodies[1] == {"on": {"value": False}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_brightness_clamps_to_0_100():
|
|
client = _make_connected_client()
|
|
|
|
await client.set_brightness(-10)
|
|
await client.set_brightness(50)
|
|
await client.set_brightness(200)
|
|
|
|
bodies = [call.kwargs["json"] for call in client._http.put.call_args_list]
|
|
assert bodies[0]["brightness"]["value"] == 0
|
|
assert bodies[1]["brightness"]["value"] == 50
|
|
assert bodies[2]["brightness"]["value"] == 100
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_color_emits_hsb_put():
|
|
client = _make_connected_client()
|
|
|
|
await client.set_color(255, 0, 0)
|
|
|
|
body = _last_put_body(client)
|
|
assert body["hue"]["value"] == 0
|
|
assert body["sat"]["value"] == 100
|
|
assert body["brightness"]["value"] == 100
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_raises_on_non_2xx_response():
|
|
client = _make_connected_client()
|
|
client._http.put = AsyncMock(return_value=httpx.Response(401, text="unauthorized"))
|
|
|
|
with pytest.raises(RuntimeError, match="rejected state update"):
|
|
await client.send_pixels(np.array([[1, 2, 3]], dtype=np.uint8))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_requires_token():
|
|
client = NanoleafClient("nanoleaf://1.2.3.4", led_count=1, auth_token="")
|
|
with pytest.raises(RuntimeError, match="requires an auth_token"):
|
|
await client.connect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_releases_http_client():
|
|
client = _make_connected_client()
|
|
http = client._http
|
|
|
|
await client.close()
|
|
|
|
http.aclose.assert_awaited()
|
|
assert client._http is None
|
|
assert client.is_connected is False
|
|
|
|
|
|
# ============================================================================
|
|
# Provider
|
|
# ============================================================================
|
|
|
|
|
|
def test_provider_device_type_and_capabilities():
|
|
provider = NanoleafDeviceProvider()
|
|
assert provider.device_type == "nanoleaf"
|
|
caps = provider.capabilities
|
|
assert "manual_led_count" in caps
|
|
assert "requires_pairing" in caps
|
|
assert "power_control" in caps
|
|
assert "brightness_control" in caps
|
|
assert "single_pixel" in caps
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_pair_device_returns_nanoleaf_token(respx_mock):
|
|
# Use a LAN address; validate_lan_host now rejects public IPs at the
|
|
# provider boundary (review HIGH #4).
|
|
respx_mock.post(f"http://192.168.1.50:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
return_value=httpx.Response(200, json={"auth_token": "ttoken"}),
|
|
)
|
|
provider = NanoleafDeviceProvider()
|
|
|
|
fields = await provider.pair_device("nanoleaf://192.168.1.50")
|
|
|
|
assert fields == {"nanoleaf_token": "ttoken"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_pair_device_propagates_pairing_not_ready(respx_mock):
|
|
respx_mock.post(f"http://192.168.1.50:{NANOLEAF_PORT}/api/v1/new").mock(
|
|
return_value=httpx.Response(403),
|
|
)
|
|
provider = NanoleafDeviceProvider()
|
|
with pytest.raises(PairingNotReady):
|
|
await provider.pair_device("nanoleaf://192.168.1.50")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_pair_device_rejects_public_ip():
|
|
"""validate_lan_host fires before pair_nanoleaf even runs."""
|
|
provider = NanoleafDeviceProvider()
|
|
with pytest.raises(ValueError, match="LedGrab is LAN-only"):
|
|
await provider.pair_device("nanoleaf://1.1.1.1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_pair_device_rejects_invalid_url():
|
|
provider = NanoleafDeviceProvider()
|
|
with pytest.raises(ValueError, match="Invalid Nanoleaf URL"):
|
|
await provider.pair_device("")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_validate_accepts_bare_host():
|
|
provider = NanoleafDeviceProvider()
|
|
assert await provider.validate_device("192.168.1.50") == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_validate_rejects_empty():
|
|
provider = NanoleafDeviceProvider()
|
|
with pytest.raises(ValueError, match="Invalid Nanoleaf URL"):
|
|
await provider.validate_device("")
|
|
|
|
|
|
def test_provider_create_client_threads_config():
|
|
provider = NanoleafDeviceProvider()
|
|
config = NanoleafConfig(
|
|
device_id="device_test",
|
|
device_url="nanoleaf://192.168.1.50",
|
|
led_count=30,
|
|
nanoleaf_token="abc-token",
|
|
nanoleaf_min_interval_ms=200,
|
|
)
|
|
|
|
client = provider.create_client(config, deps=ProviderDeps())
|
|
|
|
assert isinstance(client, NanoleafClient)
|
|
assert client.host == "192.168.1.50"
|
|
assert client._token == "abc-token"
|
|
assert client._min_interval_s == pytest.approx(0.2)
|
|
|
|
|
|
# ============================================================================
|
|
# Device.to_config() round-trip
|
|
# ============================================================================
|
|
|
|
|
|
def test_device_to_config_round_trip_nanoleaf():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Office Panels",
|
|
url="nanoleaf://192.168.1.42",
|
|
led_count=9,
|
|
device_type="nanoleaf",
|
|
nanoleaf_token="tok-xyz",
|
|
nanoleaf_min_interval_ms=150,
|
|
)
|
|
|
|
config = device.to_config()
|
|
|
|
assert isinstance(config, NanoleafConfig)
|
|
assert config.device_url == "nanoleaf://192.168.1.42"
|
|
assert config.nanoleaf_token == "tok-xyz"
|
|
assert config.nanoleaf_min_interval_ms == 150
|
|
|
|
|
|
def test_device_to_dict_omits_nanoleaf_defaults():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Default",
|
|
url="nanoleaf://192.168.1.42",
|
|
led_count=1,
|
|
device_type="nanoleaf",
|
|
)
|
|
payload = device.to_dict()
|
|
assert "nanoleaf_token" not in payload
|
|
assert "nanoleaf_min_interval_ms" not in payload
|
|
|
|
|
|
def test_device_to_dict_encrypts_nanoleaf_token():
|
|
"""The auth token must not appear in cleartext in storage."""
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Encrypted",
|
|
url="nanoleaf://192.168.1.42",
|
|
led_count=1,
|
|
device_type="nanoleaf",
|
|
nanoleaf_token="cleartext-secret-token",
|
|
)
|
|
|
|
payload = device.to_dict()
|
|
|
|
assert "nanoleaf_token" in payload
|
|
assert payload["nanoleaf_token"] != "cleartext-secret-token"
|
|
|
|
|
|
def test_device_from_dict_decrypts_nanoleaf_token():
|
|
"""from_dict + to_dict on an encrypted token must round-trip back to cleartext."""
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
original = Device(
|
|
device_id="device_abc12345",
|
|
name="Roundtrip",
|
|
url="nanoleaf://10.0.0.1",
|
|
led_count=1,
|
|
device_type="nanoleaf",
|
|
nanoleaf_token="cleartext-secret-token",
|
|
nanoleaf_min_interval_ms=200,
|
|
)
|
|
|
|
restored = Device.from_dict(original.to_dict() | {"id": original.id})
|
|
|
|
assert restored.nanoleaf_token == "cleartext-secret-token"
|
|
assert restored.nanoleaf_min_interval_ms == 200
|