Files
ledgrab/server/tests/api/routes/test_devices_routes.py
T
alexei.dolgolyov 0e3ae78de7 fix(devices): address pre-merge review findings
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.
2026-05-16 11:06:10 +03:00

420 lines
14 KiB
Python

"""Tests for device CRUD routes.
These tests exercise the FastAPI route handlers using dependency override
to inject test stores, avoiding real hardware dependencies.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api.routes.devices import router
from ledgrab.storage.device_store import Device, DeviceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.api import dependencies as deps
# ---------------------------------------------------------------------------
# App + fixtures (isolated from the real main app)
# ---------------------------------------------------------------------------
def _make_app():
"""Build a minimal FastAPI app with just the devices router + overrides."""
app = FastAPI()
app.include_router(router)
return app
@pytest.fixture
def _route_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def device_store(_route_db):
return DeviceStore(_route_db)
@pytest.fixture
def output_target_store(_route_db):
return OutputTargetStore(_route_db)
@pytest.fixture
def processor_manager():
"""A mock ProcessorManager — avoids real hardware."""
m = MagicMock(spec=ProcessorManager)
m.add_device = MagicMock()
m.remove_device = AsyncMock()
m.update_device_info = MagicMock()
m.find_device_state = MagicMock(return_value=None)
m.get_all_device_health_dicts = MagicMock(return_value=[])
return m
@pytest.fixture
def client(device_store, output_target_store, processor_manager):
app = _make_app()
# Override auth to always pass
from ledgrab.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
# Override stores and manager
app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store
app.dependency_overrides[deps.get_processor_manager] = lambda: processor_manager
return TestClient(app, raise_server_exceptions=False)
# ---------------------------------------------------------------------------
# Helper to pre-populate a device
# ---------------------------------------------------------------------------
def _seed_device(store: DeviceStore, name="Test Device", led_count=100) -> Device:
return store.create_device(
name=name,
url="http://192.168.1.100",
led_count=led_count,
)
# ---------------------------------------------------------------------------
# LIST
# ---------------------------------------------------------------------------
class TestListDevices:
def test_list_empty(self, client):
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["devices"] == []
def test_list_with_devices(self, client, device_store):
_seed_device(device_store, "Dev A")
_seed_device(device_store, "Dev B")
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 2
# ---------------------------------------------------------------------------
# GET by ID
# ---------------------------------------------------------------------------
class TestGetDevice:
def test_get_existing(self, client, device_store):
d = _seed_device(device_store)
resp = client.get(f"/api/v1/devices/{d.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == d.id
assert data["name"] == "Test Device"
def test_get_not_found(self, client):
resp = client.get("/api/v1/devices/nonexistent")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
class TestUpdateDevice:
def test_update_name(self, client, device_store):
d = _seed_device(device_store)
resp = client.put(
f"/api/v1/devices/{d.id}",
json={"name": "Renamed"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Renamed"
def test_update_led_count(self, client, device_store):
d = _seed_device(device_store, led_count=100)
resp = client.put(
f"/api/v1/devices/{d.id}",
json={"led_count": 300},
)
assert resp.status_code == 200
assert resp.json()["led_count"] == 300
def test_update_not_found(self, client):
resp = client.put(
"/api/v1/devices/missing_id",
json={"name": "X"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE
# ---------------------------------------------------------------------------
class TestDeleteDevice:
def test_delete_existing(self, client, device_store):
d = _seed_device(device_store)
resp = client.delete(f"/api/v1/devices/{d.id}")
assert resp.status_code == 204
assert device_store.count() == 0
def test_delete_not_found(self, client):
resp = client.delete("/api/v1/devices/missing_id")
assert resp.status_code == 404
def test_delete_referenced_by_target_returns_409(
self, client, device_store, output_target_store
):
d = _seed_device(device_store)
output_target_store.create_target(
name="Target",
target_type="led",
device_id=d.id,
)
resp = client.delete(f"/api/v1/devices/{d.id}")
assert resp.status_code == 409
assert "referenced" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Batch states
# ---------------------------------------------------------------------------
class TestBatchStates:
def test_batch_states(self, client):
resp = client.get("/api/v1/devices/batch/states")
assert resp.status_code == 200
assert "states" in resp.json()
# ---------------------------------------------------------------------------
# PAIRING
# ---------------------------------------------------------------------------
class _PairableProviderStub:
"""Test double that exercises the four pair_device outcomes.
Registered into the provider registry at test-time and unregistered
afterwards so the global registry stays clean across tests.
"""
def __init__(self, outcome: str):
self._outcome = outcome
@property
def device_type(self) -> str:
return "_pair_test"
@property
def capabilities(self) -> set:
return {"requires_pairing"}
def create_client(self, config, *, deps): # pragma: no cover -- not used here
raise AssertionError("Stub provider should not be asked to create a client")
async def check_health(self, url, http_client, prev_health=None): # pragma: no cover
raise AssertionError("Stub provider should not be asked for health")
async def validate_device(self, url): # pragma: no cover
return {}
async def pair_device(self, url: str):
from ledgrab.core.devices.led_client import PairingNotReady
if self._outcome == "success":
return {"_pair_test_token": "token-abc", "_pair_test_meta": 42}
if self._outcome == "not_ready":
raise PairingNotReady("Press the device button and try again.")
if self._outcome == "invalid_url":
raise ValueError("URL is missing a host")
if self._outcome == "boom":
raise RuntimeError("transport blew up")
if self._outcome == "malformed":
return "not-a-dict" # type: ignore[return-value]
if self._outcome == "not_implemented":
raise NotImplementedError("paired? what is paired?")
raise AssertionError(f"unknown outcome: {self._outcome}")
@pytest.fixture
def pair_stub_registered():
"""Register a test provider for the duration of one test."""
from ledgrab.core.devices import led_client as _led_client
registered: dict = {}
def _register(outcome: str):
provider = _PairableProviderStub(outcome)
_led_client.register_provider(provider) # type: ignore[arg-type]
registered["type"] = provider.device_type
return provider
yield _register
if "type" in registered:
_led_client._provider_registry.pop(registered["type"], None)
class TestPairDevice:
def test_returns_provider_fields_on_success(self, client, pair_stub_registered):
pair_stub_registered("success")
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "_pair_test", "url": "test://host"},
)
assert resp.status_code == 200, resp.text
assert resp.json() == {"fields": {"_pair_test_token": "token-abc", "_pair_test_meta": 42}}
def test_unknown_device_type_returns_400(self, client):
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "nonexistent_zzz", "url": "x://y"},
)
assert resp.status_code == 400
assert "unknown device type" in resp.json()["detail"].lower()
def test_not_implemented_returns_400(self, client, pair_stub_registered):
pair_stub_registered("not_implemented")
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "_pair_test", "url": "test://host"},
)
assert resp.status_code == 400
assert "does not support pairing" in resp.json()["detail"]
def test_pairing_not_ready_returns_409(self, client, pair_stub_registered):
pair_stub_registered("not_ready")
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "_pair_test", "url": "test://host"},
)
assert resp.status_code == 409
assert "press" in resp.json()["detail"].lower()
def test_invalid_url_returns_422(self, client, pair_stub_registered):
pair_stub_registered("invalid_url")
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "_pair_test", "url": "test://host"},
)
assert resp.status_code == 422
assert "host" in resp.json()["detail"]
def test_transport_exception_returns_502(self, client, pair_stub_registered):
pair_stub_registered("boom")
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "_pair_test", "url": "test://host"},
)
assert resp.status_code == 502
assert "pairing failed" in resp.json()["detail"].lower()
def test_malformed_provider_result_returns_500(self, client, pair_stub_registered):
pair_stub_registered("malformed")
resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "_pair_test", "url": "test://host"},
)
assert resp.status_code == 500
assert "malformed" in resp.json()["detail"].lower()
def test_missing_required_fields_returns_422(self, client):
resp = client.post("/api/v1/devices/pair", json={"device_type": "nanoleaf"})
assert resp.status_code == 422
class TestPairThenCreateFlow:
"""End-to-end coverage: pair, then persist; assert the token is
encrypted at rest and decrypted in to_config(), and that the API
response strips the secret.
Closes the LOW gap in the pre-merge review: pair-route tests stopped
at the 200 response, never carrying the returned fields through to
storage to verify the round-trip and the response-strip.
"""
def test_pair_then_create_persists_encrypted_token(self, client, device_store):
from ledgrab.api.routes.devices import _device_to_response
from ledgrab.core.devices import led_client as _led_client
from ledgrab.core.devices.led_client import DeviceHealth
class _NanoleafLikeStub:
@property
def device_type(self):
return "nanoleaf_like_stub"
@property
def capabilities(self):
return {"manual_led_count", "requires_pairing"}
async def pair_device(self, url):
return {"nanoleaf_token": "secret-paired-token"}
async def validate_device(self, url):
return {"led_count": 9}
def create_client(self, config, *, deps):
raise AssertionError("not used")
async def check_health(self, url, http_client, prev_health=None):
return DeviceHealth(online=True)
_led_client.register_provider(_NanoleafLikeStub())
try:
# Step 1: pair via the route
pair_resp = client.post(
"/api/v1/devices/pair",
json={"device_type": "nanoleaf_like_stub", "url": "stub://1.2.3.4"},
)
assert pair_resp.status_code == 200, pair_resp.text
fields = pair_resp.json()["fields"]
assert fields == {"nanoleaf_token": "secret-paired-token"}
# Step 2: persist via the store (skip the route's create path
# which would require a real validate_device handshake)
device = device_store.create_device(
name="E2E Paired",
url="stub://1.2.3.4",
led_count=9,
device_type="nanoleaf",
**fields,
)
# In-memory device holds plaintext
assert device.nanoleaf_token == "secret-paired-token"
# to_config surfaces plaintext to the provider
config = device.to_config()
assert config.nanoleaf_token == "secret-paired-token"
# Persisted row holds ciphertext (envelope prefix)
persisted = device.to_dict()
assert persisted["nanoleaf_token"].startswith("ENC:v1:")
assert persisted["nanoleaf_token"] != "secret-paired-token"
# API response strips the token; only a boolean flag remains
resp = _device_to_response(device).model_dump()
assert "nanoleaf_token" not in resp
assert resp.get("nanoleaf_paired") is True
finally:
_led_client._provider_registry.pop("nanoleaf_like_stub", None)