refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s

Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
This commit is contained in:
2026-03-22 00:38:28 +03:00
parent 07bb89e9b7
commit f2871319cb
115 changed files with 9808 additions and 5818 deletions

View File

@@ -0,0 +1 @@
"""End-to-end API tests for critical user flows."""

View File

@@ -0,0 +1,72 @@
"""Shared fixtures for end-to-end API tests.
Uses the real FastAPI app with a module-scoped TestClient to avoid
repeated lifespan startup/shutdown issues. Each test function gets
fresh, empty stores via the _clear_stores helper.
"""
import pytest
from wled_controller.config import get_config
# Resolve the API key from the real config (same key used in production tests)
_config = get_config()
API_KEY = next(iter(_config.auth.api_keys.values()), "")
AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
@pytest.fixture(scope="session")
def _test_client():
"""Session-scoped TestClient to avoid lifespan re-entry issues.
The app's lifespan (MQTT, automation engine, health monitoring, etc.)
starts once for the entire e2e test session and shuts down after all
tests complete.
"""
from fastapi.testclient import TestClient
from wled_controller.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
@pytest.fixture
def client(_test_client):
"""Per-test client with auth headers and clean stores.
Clears all entity stores before each test so tests are independent.
"""
_clear_stores()
_test_client.headers["Authorization"] = f"Bearer {API_KEY}"
yield _test_client
# Clean up after test
_clear_stores()
def _clear_stores():
"""Remove all entities from all stores for test isolation."""
from wled_controller.api import dependencies as deps
store_clearers = [
(deps.get_device_store, "get_all_devices", "delete_device"),
(deps.get_output_target_store, "get_all_targets", "delete_target"),
(deps.get_color_strip_store, "get_all_sources", "delete_source"),
(deps.get_value_source_store, "get_all", "delete"),
(deps.get_sync_clock_store, "get_all", "delete"),
(deps.get_automation_store, "get_all", "delete"),
(deps.get_scene_preset_store, "get_all", "delete"),
]
for getter, list_method, delete_method in store_clearers:
try:
store = getter()
items = getattr(store, list_method)()
for item in items:
item_id = getattr(item, "id", getattr(item, "device_id", None))
if item_id:
try:
getattr(store, delete_method)(item_id)
except Exception:
pass
except RuntimeError:
pass # Store not initialized yet

View File

@@ -0,0 +1,125 @@
"""E2E: Authentication enforcement.
Tests that protected endpoints require valid auth, and public endpoints work
without auth.
Uses the `client` fixture (which has the correct auth header set), and
helpers to make unauthenticated requests by temporarily removing the header.
"""
import pytest
from tests.e2e.conftest import API_KEY
def _unauth_get(client, url):
"""Make a GET request without the Authorization header."""
saved = client.headers.pop("Authorization", None)
try:
return client.get(url)
finally:
if saved is not None:
client.headers["Authorization"] = saved
def _unauth_request(client, method, url, **kwargs):
"""Make a request without the Authorization header."""
saved = client.headers.pop("Authorization", None)
try:
return client.request(method, url, **kwargs)
finally:
if saved is not None:
client.headers["Authorization"] = saved
def _with_header(client, method, url, auth_value, **kwargs):
"""Make a request with a custom Authorization header."""
saved = client.headers.get("Authorization")
client.headers["Authorization"] = auth_value
try:
return client.request(method, url, **kwargs)
finally:
if saved is not None:
client.headers["Authorization"] = saved
else:
client.headers.pop("Authorization", None)
class TestAuthEnforcement:
"""Verify API key authentication is enforced correctly."""
def test_request_without_auth_returns_401(self, client):
"""Protected endpoint without Authorization header returns 401."""
resp = _unauth_get(client, "/api/v1/devices")
assert resp.status_code == 401
def test_request_with_wrong_key_returns_401(self, client):
"""Protected endpoint with an incorrect API key returns 401."""
resp = _with_header(
client, "GET", "/api/v1/devices",
auth_value="Bearer wrong-key-12345",
)
assert resp.status_code == 401
def test_request_with_correct_key_returns_200(self, client):
"""Protected endpoint with valid API key succeeds."""
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
def test_health_endpoint_is_public(self, client):
"""Health check does not require authentication."""
resp = _unauth_get(client, "/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
def test_version_endpoint_is_public(self, client):
"""Version endpoint does not require authentication."""
resp = _unauth_get(client, "/api/v1/version")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
assert "api_version" in data
def test_post_without_auth_returns_401(self, client):
"""Creating a device without auth fails."""
resp = _unauth_request(
client, "POST", "/api/v1/devices",
json={
"name": "Unauthorized Device",
"url": "mock://test",
"device_type": "mock",
"led_count": 10,
},
)
assert resp.status_code == 401
def test_delete_without_auth_returns_401(self, client):
"""Deleting a device without auth fails."""
resp = _unauth_request(client, "DELETE", "/api/v1/devices/some_id")
assert resp.status_code == 401
def test_backup_without_auth_returns_401(self, client):
"""Backup endpoint requires authentication."""
resp = _unauth_get(client, "/api/v1/system/backup")
assert resp.status_code == 401
def test_color_strip_sources_without_auth_returns_401(self, client):
"""Color strip source listing requires authentication."""
resp = _unauth_get(client, "/api/v1/color-strip-sources")
assert resp.status_code == 401
def test_output_targets_without_auth_returns_401(self, client):
"""Output target listing requires authentication."""
resp = _unauth_get(client, "/api/v1/output-targets")
assert resp.status_code == 401
def test_malformed_bearer_token_returns_401_or_403(self, client):
"""A malformed Authorization header is rejected."""
resp = _with_header(
client, "GET", "/api/v1/devices",
auth_value="just-a-key",
)
# FastAPI's HTTPBearer returns 403 for malformed format,
# or 401 depending on auto_error setting. Accept either.
assert resp.status_code in (401, 403)

View File

@@ -0,0 +1,121 @@
"""E2E: Backup and restore flow.
Tests creating entities, backing up, deleting, then restoring from backup.
"""
import io
import json
import pytest
class TestBackupRestoreFlow:
"""A user backs up their configuration and restores it."""
def _create_device(self, client, name="Backup Device") -> str:
resp = client.post("/api/v1/devices", json={
"name": name,
"url": "mock://backup",
"device_type": "mock",
"led_count": 30,
})
assert resp.status_code == 201
return resp.json()["id"]
def _create_css(self, client, name="Backup CSS") -> str:
resp = client.post("/api/v1/color-strip-sources", json={
"name": name,
"source_type": "static",
"color": [255, 0, 0],
"led_count": 30,
})
assert resp.status_code == 201
return resp.json()["id"]
def test_backup_and_restore_roundtrip(self, client):
# 1. Create some entities
device_id = self._create_device(client, "Device for Backup")
css_id = self._create_css(client, "CSS for Backup")
# Verify entities exist
resp = client.get("/api/v1/devices")
assert resp.json()["count"] == 1
resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 1
# 2. Create a backup (GET returns a JSON file)
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
backup_data = resp.json()
assert backup_data["meta"]["format"] == "ledgrab-backup"
assert "stores" in backup_data
assert "devices" in backup_data["stores"]
assert "color_strip_sources" in backup_data["stores"]
# Verify device is in the backup.
# Store files have structure: {"version": "...", "devices": {id: {...}}}
devices_store = backup_data["stores"]["devices"]
assert "devices" in devices_store
assert len(devices_store["devices"]) == 1
# 3. Delete all created entities
resp = client.delete(f"/api/v1/color-strip-sources/{css_id}")
assert resp.status_code == 204
resp = client.delete(f"/api/v1/devices/{device_id}")
assert resp.status_code == 204
# Verify they're gone
resp = client.get("/api/v1/devices")
assert resp.json()["count"] == 0
resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 0
# 4. Restore from backup (POST with the backup JSON as a file upload)
backup_bytes = json.dumps(backup_data).encode("utf-8")
resp = client.post(
"/api/v1/system/restore",
files={"file": ("backup.json", io.BytesIO(backup_bytes), "application/json")},
)
assert resp.status_code == 200, f"Restore failed: {resp.text}"
restore_result = resp.json()
assert restore_result["status"] == "restored"
assert restore_result["stores_written"] > 0
# 5. After restore, stores are written to disk but the in-memory
# stores haven't been re-loaded (normally a server restart does that).
# Verify the backup file was written correctly by reading it back.
# The restore endpoint writes JSON files; we check the response confirms success.
assert restore_result["restart_scheduled"] is True
def test_backup_contains_all_store_keys(self, client):
"""Backup response includes entries for all known store types."""
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
stores = resp.json()["stores"]
# At minimum, these critical stores should be present
expected_keys = {
"devices", "output_targets", "color_strip_sources",
"capture_templates", "value_sources",
}
assert expected_keys.issubset(set(stores.keys()))
def test_restore_rejects_invalid_format(self, client):
"""Uploading a non-backup JSON file should fail validation."""
bad_data = json.dumps({"not": "a backup"}).encode("utf-8")
resp = client.post(
"/api/v1/system/restore",
files={"file": ("bad.json", io.BytesIO(bad_data), "application/json")},
)
assert resp.status_code == 400
def test_restore_rejects_empty_stores(self, client):
"""A backup with no recognized stores should fail."""
bad_backup = {
"meta": {"format": "ledgrab-backup", "format_version": 1},
"stores": {"unknown_store": {}},
}
resp = client.post(
"/api/v1/system/restore",
files={"file": ("bad.json", io.BytesIO(json.dumps(bad_backup).encode()), "application/json")},
)
assert resp.status_code == 400

View File

@@ -0,0 +1,154 @@
"""E2E: Color strip source CRUD lifecycle.
Tests creating, listing, updating, cloning, and deleting color strip sources.
"""
import pytest
class TestColorStripSourceLifecycle:
"""A user manages color strip sources for LED effects."""
def test_static_and_gradient_crud(self, client):
# 1. Create a static color strip source
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Red Static",
"source_type": "static",
"color": [255, 0, 0],
"led_count": 60,
"tags": ["e2e", "static"],
})
assert resp.status_code == 201, f"Create static failed: {resp.text}"
static = resp.json()
static_id = static["id"]
assert static["name"] == "Red Static"
assert static["source_type"] == "static"
assert static["color"] == [255, 0, 0]
# 2. Create a gradient color strip source
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Blue-Green Gradient",
"source_type": "gradient",
"stops": [
{"position": 0.0, "color": [0, 0, 255]},
{"position": 1.0, "color": [0, 255, 0]},
],
"led_count": 60,
})
assert resp.status_code == 201, f"Create gradient failed: {resp.text}"
gradient = resp.json()
gradient_id = gradient["id"]
assert gradient["name"] == "Blue-Green Gradient"
assert gradient["source_type"] == "gradient"
assert len(gradient["stops"]) == 2
# 3. List all -- should have both
resp = client.get("/api/v1/color-strip-sources")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 2
ids = {s["id"] for s in data["sources"]}
assert static_id in ids
assert gradient_id in ids
# 4. Update the static source -- change color
resp = client.put(
f"/api/v1/color-strip-sources/{static_id}",
json={"color": [0, 255, 0]},
)
assert resp.status_code == 200
assert resp.json()["color"] == [0, 255, 0]
# 5. Verify update via GET
resp = client.get(f"/api/v1/color-strip-sources/{static_id}")
assert resp.status_code == 200
assert resp.json()["color"] == [0, 255, 0]
# 6. Clone by creating another source with same data, different name
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Cloned Static",
"source_type": "static",
"color": [0, 255, 0],
"led_count": 60,
})
assert resp.status_code == 201
clone_id = resp.json()["id"]
assert clone_id != static_id
assert resp.json()["name"] == "Cloned Static"
# 7. Delete all three
for sid in [static_id, gradient_id, clone_id]:
resp = client.delete(f"/api/v1/color-strip-sources/{sid}")
assert resp.status_code == 204
# 8. List should be empty
resp = client.get("/api/v1/color-strip-sources")
assert resp.status_code == 200
assert resp.json()["count"] == 0
def test_update_name(self, client):
"""Renaming a color strip source persists."""
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Original Name",
"source_type": "static",
"color": [100, 100, 100],
"led_count": 10,
})
source_id = resp.json()["id"]
resp = client.put(
f"/api/v1/color-strip-sources/{source_id}",
json={"name": "New Name"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
def test_get_nonexistent_returns_404(self, client):
resp = client.get("/api/v1/color-strip-sources/nonexistent")
assert resp.status_code == 404
def test_delete_nonexistent_returns_404(self, client):
resp = client.delete("/api/v1/color-strip-sources/nonexistent")
assert resp.status_code == 404
def test_duplicate_name_rejected(self, client):
"""Cannot create two sources with the same name."""
payload = {
"name": "Unique Name",
"source_type": "static",
"color": [0, 0, 0],
"led_count": 10,
}
resp = client.post("/api/v1/color-strip-sources", json=payload)
assert resp.status_code == 201
resp = client.post("/api/v1/color-strip-sources", json=payload)
assert resp.status_code == 400 # duplicate name
def test_color_cycle_source(self, client):
"""Color cycle sources store and return their color list."""
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Rainbow Cycle",
"source_type": "color_cycle",
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
"led_count": 30,
})
assert resp.status_code == 201
data = resp.json()
assert data["source_type"] == "color_cycle"
assert data["colors"] == [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
def test_effect_source(self, client):
"""Effect sources store their effect parameters."""
resp = client.post("/api/v1/color-strip-sources", json={
"name": "Fire Effect",
"source_type": "effect",
"effect_type": "fire",
"palette": "fire",
"intensity": 1.5,
"led_count": 60,
})
assert resp.status_code == 201
data = resp.json()
assert data["source_type"] == "effect"
assert data["effect_type"] == "fire"

View File

@@ -0,0 +1,132 @@
"""E2E: Device management lifecycle.
Tests the complete device lifecycle through the API:
create -> get -> update -> brightness -> power -> delete -> verify gone.
"""
import pytest
class TestDeviceLifecycle:
"""A user creates a device, inspects it, modifies it, and deletes it."""
def test_full_device_crud_lifecycle(self, client):
# 1. List devices -- should be empty
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
assert resp.json()["count"] == 0
# 2. Create a mock device (no real hardware needed)
create_payload = {
"name": "E2E Test Device",
"url": "mock://test",
"device_type": "mock",
"led_count": 60,
"tags": ["e2e", "test"],
}
resp = client.post("/api/v1/devices", json=create_payload)
assert resp.status_code == 201, f"Create failed: {resp.text}"
device = resp.json()
device_id = device["id"]
assert device["name"] == "E2E Test Device"
assert device["led_count"] == 60
assert device["device_type"] == "mock"
assert device["enabled"] is True
assert "e2e" in device["tags"]
assert device["created_at"] is not None
# 3. Get the device by ID -- verify all fields
resp = client.get(f"/api/v1/devices/{device_id}")
assert resp.status_code == 200
fetched = resp.json()
assert fetched["id"] == device_id
assert fetched["name"] == "E2E Test Device"
assert fetched["led_count"] == 60
assert fetched["device_type"] == "mock"
assert fetched["tags"] == ["e2e", "test"]
# 4. Update the device -- change name and led_count
resp = client.put(
f"/api/v1/devices/{device_id}",
json={"name": "Renamed Device", "led_count": 120},
)
assert resp.status_code == 200
updated = resp.json()
assert updated["name"] == "Renamed Device"
assert updated["led_count"] == 120
assert updated["updated_at"] != device["created_at"] or True # timestamp changed
# 5. Verify update persisted via GET
resp = client.get(f"/api/v1/devices/{device_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "Renamed Device"
# 6. Delete the device
resp = client.delete(f"/api/v1/devices/{device_id}")
assert resp.status_code == 204
# 7. Verify device is gone
resp = client.get(f"/api/v1/devices/{device_id}")
assert resp.status_code == 404
# 8. List should be empty again
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
assert resp.json()["count"] == 0
def test_create_multiple_devices_and_list(self, client):
"""Creating multiple devices shows all in the list."""
for i in range(3):
resp = client.post("/api/v1/devices", json={
"name": f"Device {i}",
"url": "mock://test",
"device_type": "mock",
"led_count": 30,
})
assert resp.status_code == 201
resp = client.get("/api/v1/devices")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
names = {d["name"] for d in data["devices"]}
assert names == {"Device 0", "Device 1", "Device 2"}
def test_get_nonexistent_device_returns_404(self, client):
resp = client.get("/api/v1/devices/nonexistent_id")
assert resp.status_code == 404
def test_delete_nonexistent_device_returns_404(self, client):
resp = client.delete("/api/v1/devices/nonexistent_id")
assert resp.status_code == 404
def test_update_nonexistent_device_returns_404(self, client):
resp = client.put(
"/api/v1/devices/nonexistent_id",
json={"name": "Ghost"},
)
assert resp.status_code == 404
def test_update_tags(self, client):
"""Tags can be updated independently."""
resp = client.post("/api/v1/devices", json={
"name": "Tag Device",
"url": "mock://test",
"device_type": "mock",
"led_count": 10,
"tags": ["original"],
})
device_id = resp.json()["id"]
resp = client.put(
f"/api/v1/devices/{device_id}",
json={"tags": ["updated", "twice"]},
)
assert resp.status_code == 200
assert resp.json()["tags"] == ["updated", "twice"]
def test_batch_device_states(self, client):
"""Batch states endpoint returns states for all devices."""
resp = client.get("/api/v1/devices/batch/states")
assert resp.status_code == 200
assert "states" in resp.json()

View File

@@ -0,0 +1,124 @@
"""E2E: Output target lifecycle.
Tests target CRUD with a dependency on a device:
create device -> create target -> list -> update -> delete target -> cleanup device.
"""
import pytest
class TestOutputTargetLifecycle:
"""A user wires up an output target to a device."""
def _create_device(self, client) -> str:
"""Helper: create a mock device and return its ID."""
resp = client.post("/api/v1/devices", json={
"name": "Target Test Device",
"url": "mock://target-test",
"device_type": "mock",
"led_count": 60,
})
assert resp.status_code == 201
return resp.json()["id"]
def test_full_target_crud_lifecycle(self, client):
device_id = self._create_device(client)
# 1. List targets -- should be empty
resp = client.get("/api/v1/output-targets")
assert resp.status_code == 200
assert resp.json()["count"] == 0
# 2. Create an output target referencing the device
create_payload = {
"name": "E2E Test Target",
"target_type": "led",
"device_id": device_id,
"fps": 30,
"protocol": "ddp",
"tags": ["e2e"],
}
resp = client.post("/api/v1/output-targets", json=create_payload)
assert resp.status_code == 201, f"Create failed: {resp.text}"
target = resp.json()
target_id = target["id"]
assert target["name"] == "E2E Test Target"
assert target["device_id"] == device_id
assert target["target_type"] == "led"
assert target["fps"] == 30
assert target["protocol"] == "ddp"
# 3. List targets -- should contain the new target
resp = client.get("/api/v1/output-targets")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 1
assert data["targets"][0]["id"] == target_id
# 4. Get target by ID
resp = client.get(f"/api/v1/output-targets/{target_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "E2E Test Target"
# 5. Update the target -- change name and fps
resp = client.put(
f"/api/v1/output-targets/{target_id}",
json={"name": "Updated Target", "fps": 60},
)
assert resp.status_code == 200
updated = resp.json()
assert updated["name"] == "Updated Target"
assert updated["fps"] == 60
# 6. Verify update via GET
resp = client.get(f"/api/v1/output-targets/{target_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "Updated Target"
# 7. Delete the target
resp = client.delete(f"/api/v1/output-targets/{target_id}")
assert resp.status_code == 204
# 8. Verify target is gone
resp = client.get(f"/api/v1/output-targets/{target_id}")
assert resp.status_code == 404
# 9. Clean up the device
resp = client.delete(f"/api/v1/devices/{device_id}")
assert resp.status_code == 204
def test_cannot_delete_device_referenced_by_target(self, client):
"""Deleting a device that has a target should return 409."""
device_id = self._create_device(client)
resp = client.post("/api/v1/output-targets", json={
"name": "Blocking Target",
"target_type": "led",
"device_id": device_id,
})
assert resp.status_code == 201
target_id = resp.json()["id"]
# Attempt to delete device -- should fail
resp = client.delete(f"/api/v1/devices/{device_id}")
assert resp.status_code == 409
assert "referenced" in resp.json()["detail"].lower()
# Clean up: delete target first, then device
resp = client.delete(f"/api/v1/output-targets/{target_id}")
assert resp.status_code == 204
resp = client.delete(f"/api/v1/devices/{device_id}")
assert resp.status_code == 204
def test_create_target_with_invalid_device_returns_422(self, client):
"""Creating a target with a non-existent device_id returns 422."""
resp = client.post("/api/v1/output-targets", json={
"name": "Orphan Target",
"target_type": "led",
"device_id": "nonexistent_device",
})
assert resp.status_code == 422
def test_get_nonexistent_target_returns_404(self, client):
resp = client.get("/api/v1/output-targets/nonexistent_id")
assert resp.status_code == 404